feat: v4.0.0 大版本升级 - SQLite3 + ReAct Agent

- P0: 存储层 IndexedDB → SQLite3 (better-sqlite3),7张表 + FTS5全文搜索
- P1: Agent Engine 升级为 ReAct 模式(思考→行动→观察→反思)
- P2: 新增 4 个工具(memory_search, memory_add, session_list, session_read)
- P3: 上下文管理器(滑动窗口 + 摘要压缩 + 记忆注入)
- P4: UI 改版(ReAct 执行面板样式 + 思考过程卡片)
- P5: IndexedDB → SQLite 数据迁移支持
- MAX_LOOPS 10→15, MAX_LOOP_TIME 5min→10min, 错误自动重试 2 次
This commit is contained in:
thzxx
2026-04-17 12:35:42 +08:00
parent 5fb36a93e6
commit 26cbac71fe
14 changed files with 2001 additions and 332 deletions
+206 -135
View File
@@ -1,6 +1,7 @@
/**
* Agent Engine - Agent Loop 核心引擎
* 管理带工具调用的流式对话循环
* Agent Engine - ReAct Agent Loop 核心引擎 (v4.0)
* ReAct 模式: Thought → Action → Observation → Reflection
* 支持任务复杂度评估、自动规划、错误重试、执行轨迹记录
*/
import { OllamaAPI } from '../api/ollama.js';
@@ -12,35 +13,79 @@ import {
} from './tool-registry.js';
import { searchMemories, buildMemoryContext, markMemoryUsed, isMemoryEnabled } from './memory-manager.js';
import { showToast } from '../components/toast.js';
import { logInfo, logWarn, logError, logToolStart, logToolResult, logThink, logStream, logAgentLoop, logModelResponse } from './log-service.js';
import { logInfo, logWarn, logError, logToolStart, logToolResult, logThink, logAgentLoop, logModelResponse } from './log-service.js';
import { getWorkspaceDirPath } from '../components/workspace-panel.js';
import { generateId } from '../utils/utils.js';
import type {
OllamaMessage,
OllamaStreamChunk,
ToolCall,
ToolResult,
ToolCallRecord
ToolCallRecord,
TraceEntry
} from '../types.js';
const MAX_LOOPS = 10;
const MAX_LOOP_TIME = 300000; // 5 分钟总超时
const TOOL_EXEC_TIMEOUT = 30000; // 工具执行超时 30 秒
const STREAM_TIMEOUT = 120000; // 单次流式调用超时 2 分钟
const MAX_LOOPS = 15;
const MAX_LOOP_TIME = 600000; // 10 分钟总超时
const TOOL_EXEC_TIMEOUT = 30000; // 工具执行超时 30 秒
const STREAM_TIMEOUT = 120000; // 单次流式调用超时 2 分钟
const MAX_RETRIES = 2; // 工具错误自动重试次数
const REACT_SYSTEM_PROMPT = `你是一个具备 ReActReasoning + Acting)能力的 AI 助手。你必须严格按照以下格式与用户交互:
## ReAct 输出格式
每次响应时,你必须按照以下结构思考和行动:
**Thought:** [你对当前任务的分析和推理。思考用户需要什么、当前有哪些信息、下一步应该做什么。]
**Action:** [如果需要使用工具,选择一个工具并说明原因。如果不需要工具,直接给出最终答案。]
**Action Input:** [工具的参数,必须是合法 JSON 格式]
然后等待 Observation(工具返回的结果)。
收到 Observation 后:
**Thought:** [基于工具结果的进一步推理。结果是否满足需求?是否需要更多步骤?]
**Action:** [下一步行动,或者 "Final Answer" 如果任务已完成]
**Action Input:** [下一步工具参数,或者最终答案]
## 任务复杂度评估
在开始时评估任务:
- **简单**:直接回答(1 步),不需要工具或仅需 1 个工具调用
- **中等**:需要 2-4 步,可能需要多个工具协作
- **复杂**:需要 5+ 步,需要规划和分解
对于复杂任务,先输出一个简短的执行计划,再开始 ReAct 循环。
## 错误处理
- 如果工具返回错误,分析原因并尝试不同方法
- 最多重试 2 次,如果仍然失败,向用户说明情况
- 不要反复调用相同参数的相同工具
## 最终答案
当任务完成时,输出 **Final Answer:** 后面跟你的最终回答。回答应该:
- 汇总所有执行步骤和结果
- 直接回答用户的问题
- 如果有重要发现,明确指出`;
const TOOL_USAGE_GUIDE = `【工具使用规则】
1. 路径上下文:当用户说"修改这个文件"、"删除它"、"查看里面"等未指定路径时,必须从本次对话中最近的工具调用结果中提取路径。例如用户之前让你读取了 /home/user/project/config.json,后续说"修改配置"就应继续使用该路径,而不是推测新路径。
1. 路径上下文:当用户说"修改这个文件"、"删除它"、"查看里面"等未指定路径时,必须从本次对话中最近的工具调用结果中提取路径。
2. 工具调用结果中的 path 字段是权威的文件路径,优先使用它。
3. 如果对话中从未出现过相关路径,应先用 search_files 或 list_directory 定位,不要凭空猜测路径。
4. 对同一文件的连续操作(读取→修改、读取→删除),路径必须一致。
5. 当用户要求执行命令时,使用 run_command 工具执行。命令通过工作空间终端实时执行并显示,执行完成后将 stdout/stderr 结果告知用户。
6. 联网搜索联动:当用户询问需要最新信息的问题时(如版本号、新闻、价格等),先用 web_search 搜索,然后必须从搜索结果中选择最相关的 1-3 个 URL,用 web_fetch 抓取页面详细内容获取准确数据。不要仅凭搜索摘要回答——摘要往往不完整,必须抓取原文才能给出准确答案
7. 主动使用工具:当你不确定答案、信息可能已过时、或需要查阅实时数据时,主动调用工具而不是凭记忆猜测。以下场景必须使用工具:
- 用户问"最新版本"、"现在多少钱"、"最近有什么新闻" → web_search + web_fetch
- 用户让你查看/修改/分析文件 → read_file / list_directory / search_files
- 用户问项目状态、代码内容 → git status / read_file
- 用户让你执行操作 → run_command / write_file 等
宁可多调一次工具,也不要给出过时或编造的信息。
8. 严禁重复调用:如果本次对话中已经成功调用过某个工具并获得了结果,不要再次调用相同工具和相同参数。直接使用已有结果继续工作。例如:已经 web_fetch 抓取了页面内容,下一步应直接处理该内容(如 write_file 保存),而不是重新抓取。`;
5. 当用户要求执行命令时,使用 run_command 工具执行。
6. 联网搜索联动:当用户询问需要最新信息的问题时,先用 web_search 搜索,然后必须从搜索结果中选择最相关的 1-3 个 URL,用 web_fetch 抓取页面详细内容。
7. 主动使用工具:当你不确定答案、信息可能已过时、或需要查阅实时数据时,主动调用工具而不是凭记忆猜测。
8. 严禁重复调用:如果本次对话中已经成功调用过某个工具并获得了结果,不要再次调用相同工具和相同参数。
9. 记忆工具:你可以使用 memory_search 搜索过去的记忆,使用 memory_add 添加新的重要记忆。
10. 会话工具:你可以使用 session_list 列出历史会话,使用 session_read 读取历史会话内容。`;
/** 工具调用缓存:key = toolName + JSON(args),用于避免重复调用 */
const toolResultCache = new Map<string, ToolResult>();
@@ -48,7 +93,6 @@ const toolResultCache = new Map<string, ToolResult>();
/** 生成工具调用缓存 key */
function getToolCacheKey(name: string, args: Record<string, unknown>): string {
try {
// 对 arguments 排序 key 以保证一致性
return name + '::' + JSON.stringify(args, Object.keys(args).sort());
} catch {
return name + '::' + String(args);
@@ -56,10 +100,10 @@ function getToolCacheKey(name: string, args: Record<string, unknown>): string {
}
/** 检测当前轮次是否存在重复工具调用 */
function isDuplicateCall(call: ToolCall, loopCalls: ToolCall[]): boolean {
function isDuplicateCall(call: ToolCall, allCalls: ToolCall[]): boolean {
const callKey = getToolCacheKey(call.function.name, call.function.arguments);
return loopCalls.some((prev, idx) => {
if (idx === loopCalls.length - 1) return false; // 跳过自身
return allCalls.some((prev, idx) => {
if (idx === allCalls.length - 1) return false;
return getToolCacheKey(prev.function.name, prev.function.arguments) === callKey;
});
}
@@ -74,6 +118,26 @@ export interface AgentCallbacks {
onConfirmTool: (call: ToolCall) => Promise<boolean>;
}
/** 保存执行轨迹到 SQLite */
async function saveTrace(trace: Omit<TraceEntry, 'id' | 'createdAt'>): Promise<void> {
try {
const bridge = (window as any).metonaDesktop;
if (!bridge?.db) return;
const entry = {
id: `trace_${generateId()}`,
session_id: trace.sessionId,
step_index: trace.stepIndex,
thought: trace.thought,
action: trace.action,
action_input: trace.actionInput,
observation: trace.observation,
loop_count: trace.loopCount,
created_at: trace.createdAt
};
await bridge.db.saveTrace(entry);
} catch { /* 不阻塞主流程 */ }
}
export async function runAgentLoop(
userContent: string,
images: string[],
@@ -82,6 +146,8 @@ export async function runAgentLoop(
): Promise<void> {
const api = state.get<OllamaAPI>(KEYS.API);
const model = state.get<string>('_defaultModel', '');
const currentSession = state.get(KEYS.CURRENT_SESSION);
const sessionId = currentSession?.id || 'unknown';
if (!api || !model) {
showToast('请先选择模型', 'error');
@@ -93,12 +159,8 @@ export async function runAgentLoop(
// 检查模型是否支持 Tool Calling
const modelSupportsTools = state.get<boolean>('modelSupportsTools', false);
if (!modelSupportsTools) {
logWarn(`模型 ${model} 不支持 Tool Calling`, 'Agent Loop 可能不稳定');
}
const messages: OllamaMessage[] = [];
let systemPromptParts: string[] = [];
// 注入记忆上下文
@@ -121,12 +183,14 @@ export async function runAgentLoop(
文件操作工具(read_file、write_file 等)的相对路径基于此目录解析。`);
}
if (systemPromptParts.length > 0) {
messages.push({ role: 'system', content: systemPromptParts.join('\n\n') + '\n\n' + TOOL_USAGE_GUIDE });
} else {
messages.push({ role: 'system', content: TOOL_USAGE_GUIDE });
}
// 组合 system prompt
const fullSystemPrompt = systemPromptParts.length > 0
? systemPromptParts.join('\n\n') + '\n\n' + REACT_SYSTEM_PROMPT + '\n\n' + TOOL_USAGE_GUIDE
: REACT_SYSTEM_PROMPT + '\n\n' + TOOL_USAGE_GUIDE;
messages.push({ role: 'system', content: fullSystemPrompt });
// 添加历史消息(使用 context-manager 构建)
for (const msg of historyMessages) {
messages.push({
role: msg.role as 'user' | 'assistant',
@@ -135,6 +199,7 @@ export async function runAgentLoop(
});
}
// 用户消息
const userMsg: OllamaMessage = {
role: 'user',
content: userContent || (images?.length ? (images.length > 1 ? `请分析这 ${images.length} 张图片` : '请分析这张图片') : ''),
@@ -145,18 +210,19 @@ export async function runAgentLoop(
const tools = getEnabledToolDefinitions();
const useTools = tools.length > 0;
logInfo(`Agent Loop 启动: ${model}`, `工具: ${useTools ? '开启' : '关闭'}, 记忆: ${isMemoryEnabled() ? '开启' : '关闭'}`);
logInfo(`ReAct Agent Loop 启动: ${model}`, `工具: ${useTools ? '开启' : '关闭'}, 记忆: ${isMemoryEnabled() ? '开启' : '关闭'}`);
let loopCount = 0;
const allToolRecords: ToolCallRecord[] = [];
const loopStartTime = Date.now();
let content = '';
let totalEvalCount = 0;
/** 记录前一轮的工具调用 key,用于跨轮去重 */
let prevLoopToolKeys: string[] = [];
/** 每轮工具调用计数(用于去重) */
let allCallsThisLoop: ToolCall[] = [];
const makeStats = () => {
const totalDuration = (Date.now() - loopStartTime) * 1e6; // 转为纳秒
const totalDuration = (Date.now() - loopStartTime) * 1e6;
return { eval_count: totalEvalCount || undefined, total_duration: totalDuration };
};
@@ -165,14 +231,14 @@ export async function runAgentLoop(
// 全局超时检查
if (Date.now() - loopStartTime > MAX_LOOP_TIME) {
logWarn('Agent Loop 达到最大运行时间(5分钟),自动停止');
logWarn('ReAct Agent Loop 达到最大运行时间(10分钟),自动停止');
callbacks.onDone(content || '(达到最大运行时间限制)', allToolRecords, makeStats());
return;
}
// 检查是否已中止
if (state.get<AbortController | null>(KEYS.ABORT_CONTROLLER)?.signal.aborted) {
logInfo('Agent Loop 已中止');
logInfo('ReAct Agent Loop 已中止');
callbacks.onDone(content, allToolRecords.length > 0 ? allToolRecords : undefined, makeStats());
return;
}
@@ -182,6 +248,7 @@ export async function runAgentLoop(
let thinking = '';
content = '';
const toolCalls: ToolCall[] = [];
allCallsThisLoop = [];
const abortController = new AbortController();
state.set(KEYS.ABORT_CONTROLLER, abortController);
@@ -205,10 +272,6 @@ export async function runAgentLoop(
if (chunk.message?.thinking) {
thinking += chunk.message.thinking;
callbacks.onThinking(thinking);
// 每收到 200 字符记录一次思考日志
if (thinking.length % 200 < 10) {
logThink(thinking);
}
}
if (chunk.message?.content) {
content += chunk.message.content;
@@ -218,7 +281,6 @@ export async function runAgentLoop(
if (chunk.message?.tool_calls?.length) {
for (const tc of chunk.message.tool_calls) {
if (tc.function?.name) {
// 新的工具调用
toolCalls.push({
type: 'function',
function: {
@@ -227,7 +289,6 @@ export async function runAgentLoop(
}
});
} else if (toolCalls.length > 0) {
// 增量 arguments 追加到当前最后一个 tool_call
const last = toolCalls[toolCalls.length - 1];
if (tc.function?.arguments && typeof tc.function.arguments === 'object') {
Object.assign(last.function.arguments, tc.function.arguments);
@@ -246,24 +307,15 @@ export async function runAgentLoop(
if (abortController.signal.aborted) {
logInfo('流式调用已中止');
if (content || thinking) {
messages.push({
role: 'assistant',
content,
...(thinking && { thinking })
});
messages.push({ role: 'assistant', content, ...(thinking && { thinking }) });
}
callbacks.onDone(content, allToolRecords.length > 0 ? allToolRecords : undefined, makeStats());
return;
}
// 超时错误 — 保留已生成的内容
if ((err as Error).message.includes('超时')) {
logError('流式调用超时2分钟无数据)', (err as Error).message);
logError('流式调用超时', (err as Error).message);
if (content || thinking) {
messages.push({
role: 'assistant',
content: content || '(模型响应超时)',
...(thinking && { thinking })
});
messages.push({ role: 'assistant', content: content || '(模型响应超时)', ...(thinking && { thinking }) });
}
callbacks.onDone(content || '(模型响应超时,已自动中断)', allToolRecords.length > 0 ? allToolRecords : undefined, makeStats());
return;
@@ -271,6 +323,11 @@ export async function runAgentLoop(
throw err;
}
// 提取 ReAct 思考过程
const thoughtMatch = content.match(/\*\*Thought:\*\*\s*([\s\S]*?)(?=\*\*Action:\*\*|\*\*Final Answer:\*\*|$)/i);
const thought = thoughtMatch ? thoughtMatch[1].trim() : '';
// 保存 assistant 消息
const assistantMsg: OllamaMessage = {
role: 'assistant',
content,
@@ -278,30 +335,54 @@ export async function runAgentLoop(
};
if (toolCalls.length > 0) {
assistantMsg.tool_calls = toolCalls;
// 官方示例要求:tool_calls 时同时保留 content 和 thinking
// 确保 content 不为空(模型可能在调用工具前说了话)
}
messages.push(assistantMsg);
logModelResponse(content.length, toolCalls.length);
// 检查是否是 Final Answer(没有工具调用且包含 Final Answer
const isFinalAnswer = content.includes('Final Answer:') || content.includes('最终答案:');
if (toolCalls.length === 0) {
logInfo('无工具调用,Agent Loop 结束');
logInfo('无工具调用,ReAct Agent Loop 结束');
// 自动提取记忆
if (isMemoryEnabled() && messages.length >= 4) {
try {
const { extractMemoriesFromConversation } = await import('./memory-manager.js');
await extractMemoriesFromConversation(
messages.filter(m => m.role === 'user' || m.role === 'assistant').map(m => ({ role: m.role, content: m.content })),
currentSession?.title
);
} catch { /* 不阻塞 */ }
}
callbacks.onDone(content, allToolRecords.length > 0 ? allToolRecords : undefined, makeStats());
return;
}
// 跨轮次完整重复检测:如果本轮所有工具调用与上一轮完全相同,终止循环
// 跨轮次完整重复检测
const currentLoopKeys = toolCalls.map(c => getToolCacheKey(c.function.name, c.function.arguments)).sort();
const currentKeysStr = JSON.stringify(currentLoopKeys);
const prevKeysStr = JSON.stringify([...prevLoopToolKeys].sort());
if (currentKeysStr === prevKeysStr && currentLoopKeys.length > 0) {
logWarn('检测到连续两轮工具调用完全相同,终止 Agent Loop', currentKeysStr.slice(0, 200));
logWarn('检测到连续两轮工具调用完全相同,终止 ReAct Loop', currentKeysStr.slice(0, 200));
callbacks.onDone(content || '(检测到重复工具调用,已自动停止)', allToolRecords, makeStats());
return;
}
prevLoopToolKeys = currentLoopKeys;
// 记录 ReAct 轨迹
const traceStep = {
sessionId,
stepIndex: loopCount,
thought: thought || content.slice(0, 200),
action: toolCalls.map(t => t.function.name).join(', '),
actionInput: JSON.stringify(toolCalls.map(t => t.function.arguments)),
observation: '',
loopCount,
createdAt: Date.now()
};
for (const call of toolCalls) {
// 检查是否已中止
if (abortController.signal.aborted) {
@@ -311,30 +392,22 @@ export async function runAgentLoop(
// 同一轮内重复调用检测
if (isDuplicateCall(call, toolCalls)) {
logWarn(`跳过重复工具调用: ${call.function.name}`, JSON.stringify(call.function.arguments).slice(0, 200));
logWarn(`跳过重复工具调用: ${call.function.name}`);
const cachedKey = getToolCacheKey(call.function.name, call.function.arguments);
const cached = toolResultCache.get(cachedKey);
if (cached) {
messages.push({
role: 'tool',
tool_name: call.function.name,
content: JSON.stringify(cached)
});
messages.push({ role: 'tool', tool_name: call.function.name, content: JSON.stringify(cached) });
callbacks.onToolCallResult(call.function.name, cached, call);
}
continue;
}
// 跨轮次缓存命中检测
// 跨轮次缓存命中
const cacheKey = getToolCacheKey(call.function.name, call.function.arguments);
const cachedResult = toolResultCache.get(cacheKey);
if (cachedResult) {
logInfo(`工具缓存命中: ${call.function.name}`, JSON.stringify(call.function.arguments).slice(0, 200));
messages.push({
role: 'tool',
tool_name: call.function.name,
content: JSON.stringify(cachedResult)
});
logInfo(`工具缓存命中: ${call.function.name}`);
messages.push({ role: 'tool', tool_name: call.function.name, content: JSON.stringify(cachedResult) });
callbacks.onToolCallResult(call.function.name, cachedResult, call);
continue;
}
@@ -344,13 +417,12 @@ export async function runAgentLoop(
if (needsConfirmation(call.function.name)) {
const confirmed = await callbacks.onConfirmTool(call);
// 确认后再次检查是否已中止
if (abortController.signal.aborted) {
callbacks.onDone(content, allToolRecords.length > 0 ? allToolRecords : undefined, makeStats());
return;
}
if (!confirmed) {
logWarn(`工具取消: ${call.function.name}`, '用户取消了操作');
logWarn(`工具取消: ${call.function.name}`);
const record: ToolCallRecord = {
name: call.function.name,
arguments: call.function.arguments,
@@ -359,77 +431,76 @@ export async function runAgentLoop(
timestamp: Date.now()
};
allToolRecords.push(record);
messages.push({
role: 'tool',
tool_name: call.function.name,
content: JSON.stringify({ success: false, error: '用户取消了操作' })
});
messages.push({ role: 'tool', tool_name: call.function.name, content: JSON.stringify({ success: false, error: '用户取消了操作' }) });
callbacks.onToolCallError(call.function.name, '用户取消', call);
continue;
}
}
try {
// 工具执行(run_command 无超时,其他工具 30 秒超时)
logInfo(`执行工具: ${call.function.name}`, JSON.stringify(call.function.arguments));
let result: ToolResult;
if (call.function.name === 'run_command') {
// run_command 通过 workspace IPC 执行,无超时限制
result = await executeTool(call.function.name, call.function.arguments);
} else {
// 其他工具保持 30 秒超时保护
result = await Promise.race([
executeTool(call.function.name, call.function.arguments).catch(err =>
({ success: false, error: err?.message || String(err) }) as ToolResult
),
new Promise<ToolResult>((_, reject) =>
setTimeout(() => reject(new Error('工具执行超时(30秒)')), TOOL_EXEC_TIMEOUT)
)
]);
}
const record: ToolCallRecord = {
name: call.function.name,
arguments: call.function.arguments,
result,
status: result.success ? 'success' : 'error',
timestamp: Date.now()
};
allToolRecords.push(record);
// 工具执行 + 自动重试
let lastError = '';
for (let retry = 0; retry <= MAX_RETRIES; retry++) {
try {
let result: ToolResult;
if (call.function.name === 'run_command') {
result = await executeTool(call.function.name, call.function.arguments);
} else {
result = await Promise.race([
executeTool(call.function.name, call.function.arguments).catch(err =>
({ success: false, error: err?.message || String(err) }) as ToolResult
),
new Promise<ToolResult>((_, reject) =>
setTimeout(() => reject(new Error('工具执行超时(30秒)')), TOOL_EXEC_TIMEOUT)
)
]);
}
messages.push({
role: 'tool',
tool_name: call.function.name,
content: JSON.stringify(result)
});
// 缓存成功结果(非 run_command,因为命令执行可能有副作用)
if (result.success && call.function.name !== 'run_command') {
toolResultCache.set(cacheKey, result);
// 成功
const record: ToolCallRecord = {
name: call.function.name,
arguments: call.function.arguments,
result,
status: result.success ? 'success' : 'error',
timestamp: Date.now()
};
allToolRecords.push(record);
messages.push({ role: 'tool', tool_name: call.function.name, content: JSON.stringify(result) });
if (result.success && call.function.name !== 'run_command') {
toolResultCache.set(cacheKey, result);
}
callbacks.onToolCallResult(call.function.name, result, call);
logToolResult(call.function.name, result.success, result.success ? undefined : result.error);
break; // 成功,退出重试循环
} catch (err) {
lastError = (err as Error).message;
if (retry < MAX_RETRIES) {
logWarn(`工具重试 ${retry + 1}/${MAX_RETRIES}: ${call.function.name}`, lastError);
await new Promise(r => setTimeout(r, 500)); // 短暂延迟后重试
continue;
}
// 最终失败
logError(`工具执行失败: ${call.function.name}`, lastError);
const record: ToolCallRecord = {
name: call.function.name,
arguments: call.function.arguments,
result: { success: false, error: lastError },
status: 'error',
timestamp: Date.now()
};
allToolRecords.push(record);
messages.push({ role: 'tool', tool_name: call.function.name, content: JSON.stringify({ success: false, error: lastError }) });
callbacks.onToolCallError(call.function.name, lastError, call);
}
callbacks.onToolCallResult(call.function.name, result, call);
logToolResult(call.function.name, result.success, result.success ? undefined : result.error);
} catch (err) {
const errorMsg = (err as Error).message;
logError(`工具执行失败: ${call.function.name}`, errorMsg);
const record: ToolCallRecord = {
name: call.function.name,
arguments: call.function.arguments,
result: { success: false, error: errorMsg },
status: 'error',
timestamp: Date.now()
};
allToolRecords.push(record);
messages.push({
role: 'tool',
tool_name: call.function.name,
content: JSON.stringify({ success: false, error: errorMsg })
});
callbacks.onToolCallError(call.function.name, errorMsg, call);
}
}
// 保存本轮轨迹
traceStep.observation = allToolRecords.slice(-toolCalls.length).map(r =>
`${r.name}: ${r.result?.success ? 'success' : 'error'}`
).join('; ');
saveTrace(traceStep);
}
logWarn('Agent Loop 达到最大工具调用次数限制');
logWarn('ReAct Agent Loop 达到最大工具调用次数限制');
callbacks.onDone(content || '(达到最大工具调用次数限制)', allToolRecords, makeStats());
}
+175
View File
@@ -0,0 +1,175 @@
/**
* Context Manager - 智能上下文管理 (v4.0)
* 三层策略:滑动窗口 + 消息摘要压缩 + 记忆注入
*/
import type { OllamaMessage } from '../types.js';
import { logInfo, logWarn } from './log-service.js';
/** 粗略估算 token 数 */
export function estimateTokens(text: string): number {
if (!text) return 0;
// 中文按 1.5 字/token,英文按 4 字符/token
let chineseChars = 0;
let otherChars = 0;
for (const ch of text) {
if (/[\u4e00-\u9fff]/.test(ch)) chineseChars++;
else otherChars++;
}
return Math.ceil(chineseChars / 1.5 + otherChars / 4);
}
export interface ContextBuildOptions {
/** 滑动窗口大小(最近 N 条消息完整保留) */
windowSize?: number;
/** 每 N 条更早的消息压缩为一段摘要 */
summaryBatchSize?: number;
/** 最大 token 数限制 */
maxTokens?: number;
/** 系统 prompt 注入的记忆上下文 */
memoryContext?: string;
/** 工作空间上下文 */
workspaceContext?: string;
}
const DEFAULT_OPTIONS: Required<ContextBuildOptions> = {
windowSize: 20,
summaryBatchSize: 20,
maxTokens: 24576,
memoryContext: '',
workspaceContext: ''
};
/**
* 构建发送给模型的 messages
* 三层策略:
* a. 滑动窗口:最近 N 条消息完整保留
* b. 更早的消息:每 N 条压缩为一段摘要
* c. 系统 prompt 注入记忆上下文
*/
export function buildContext(
allMessages: OllamaMessage[],
options: ContextBuildOptions = {}
): OllamaMessage[] {
const opts = { ...DEFAULT_OPTIONS, ...options };
const result: OllamaMessage[] = [];
// 系统 prompt
let systemContent = '';
if (opts.memoryContext) {
systemContent += opts.memoryContext + '\n\n';
}
if (opts.workspaceContext) {
systemContent += opts.workspaceContext + '\n\n';
}
// 提取已有的 system 消息
const existingSystem = allMessages.find(m => m.role === 'system');
if (existingSystem) {
systemContent += existingSystem.content;
}
if (systemContent.trim()) {
result.push({ role: 'system', content: systemContent.trim() });
}
// 非 system 消息
const nonSystemMessages = allMessages.filter(m => m.role !== 'system');
if (nonSystemMessages.length <= opts.windowSize) {
// 消息不多,全部保留
result.push(...nonSystemMessages);
return result;
}
// 滑动窗口:最近 N 条
const recentMessages = nonSystemMessages.slice(-opts.windowSize);
// 更早的消息:压缩为摘要
const olderMessages = nonSystemMessages.slice(0, -opts.windowSize);
const summaries = summarizeOlderMessages(olderMessages, opts.summaryBatchSize);
result.push(...summaries, ...recentMessages);
// Token 估算和裁剪
const trimmed = trimByTokenLimit(result, opts.maxTokens);
logInfo(`上下文构建: ${nonSystemMessages.length} 条消息 → ${trimmed.length} 条 (窗口: ${opts.windowSize})`,
`估算 tokens: ${estimateTokens(trimmed.map(m => m.content).join(''))}`);
return trimmed;
}
/**
* 将较早的消息每 batchSize 条压缩为一段摘要
*/
function summarizeOlderMessages(messages: OllamaMessage[], batchSize: number): OllamaMessage[] {
const summaries: OllamaMessage[] = [];
for (let i = 0; i < messages.length; i += batchSize) {
const batch = messages.slice(i, i + batchSize);
const summary = createQuickSummary(batch);
summaries.push({
role: 'system',
content: `【更早的对话摘要(第 ${Math.floor(i / batchSize) + 1} 部分)】\n${summary}`
});
}
return summaries;
}
/**
* 快速摘要(不调用模型,直接提取关键信息)
*/
function createQuickSummary(messages: OllamaMessage[]): string {
const parts: string[] = [];
for (const msg of messages) {
const role = msg.role === 'user' ? '用户' : 'AI';
const content = msg.content || '';
// 取前 100 字符作为摘要
const preview = content.length > 100 ? content.slice(0, 100) + '...' : content;
if (preview.trim()) {
parts.push(`${role}: ${preview}`);
}
// 如果有工具调用,记录工具名
if (msg.tool_calls?.length) {
const toolNames = msg.tool_calls.map(t => t.function.name).join(', ');
parts.push(` [工具: ${toolNames}]`);
}
}
return parts.join('\n');
}
/**
* 根据 token 限制裁剪消息
*/
function trimByTokenLimit(messages: OllamaMessage[], maxTokens: number): OllamaMessage[] {
let totalTokens = 0;
const result: OllamaMessage[] = [];
// 从后往前添加(优先保留最近的消息)
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
const msgTokens = estimateTokens(msg.content || '') +
(msg.images ? msg.images.length * 100 : 0) +
(msg.tool_calls ? msg.tool_calls.length * 50 : 0);
// system 消息始终保留
if (msg.role === 'system') {
totalTokens += msgTokens;
result.unshift(msg);
continue;
}
if (totalTokens + msgTokens > maxTokens && result.length > 2) {
break; // 已到限制,保留至少 system + 1 条消息
}
totalTokens += msgTokens;
result.unshift(msg);
}
return result;
}
+146 -43
View File
@@ -350,6 +350,71 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
}
}
}
},
// ══════════════════════════════════════════════
// v4.0 新增工具:记忆和会话管理
// ══════════════════════════════════════════════
{
type: 'function',
function: {
name: 'memory_search',
description: 'Search agent memories by semantic similarity or keywords. Returns relevant memories about the user, preferences, rules, and past conversations.',
parameters: {
type: 'object',
required: ['query'],
properties: {
query: { type: 'string', description: 'Search query for memories.' },
limit: { type: 'integer', description: 'Max results to return. Default: 8.' },
type: { type: 'string', enum: ['fact', 'preference', 'rule'], description: 'Filter by memory type.' }
}
}
}
},
{
type: 'function',
function: {
name: 'memory_add',
description: 'Add a new memory entry for future reference. Use when you learn something important about the user, their preferences, or rules they want you to follow.',
parameters: {
type: 'object',
required: ['type', 'content'],
properties: {
type: { type: 'string', enum: ['fact', 'preference', 'rule'], description: 'Memory type: fact (about user), preference (user preference), rule (must follow).' },
content: { type: 'string', description: 'The memory content. Be concise and specific.' },
importance: { type: 'integer', description: 'Importance 1-10. Higher = more important. Default: 5.' },
tags: { type: 'array', items: { type: 'string' }, description: 'Tags for search matching. 2-5 keywords.' }
}
}
}
},
{
type: 'function',
function: {
name: 'session_list',
description: 'List previous chat sessions with their titles and timestamps. Useful for referencing past conversations.',
parameters: {
type: 'object',
properties: {
limit: { type: 'integer', description: 'Max sessions to return. Default: 20.' },
search: { type: 'string', description: 'Filter sessions by title keyword.' }
}
}
}
},
{
type: 'function',
function: {
name: 'session_read',
description: 'Read the messages from a previous chat session. Use when the user references something from a past conversation.',
parameters: {
type: 'object',
required: ['session_id'],
properties: {
session_id: { type: 'string', description: 'The session ID to read.' },
max_messages: { type: 'integer', description: 'Max messages to return. Default: 50.' }
}
}
}
}
];
@@ -380,7 +445,8 @@ let enabledTools: Set<string> = new Set([
'run_command',
'move_file', 'copy_file', 'web_fetch', 'web_search', 'append_file', 'edit_file',
'get_file_info', 'tree', 'download_file', 'diff_files', 'replace_in_files',
'read_multiple_files', 'git', 'compress'
'read_multiple_files', 'git', 'compress',
'memory_search', 'memory_add', 'session_list', 'session_read'
]);
export function setToolEnabled(toolName: string, enabled: boolean): void {
@@ -409,6 +475,70 @@ export async function executeTool(toolName: string, args: Record<string, unknown
}
try {
// 内存工具(不走 IPC,直接处理)
if (toolName === 'memory_search') {
const { searchMemories } = await import('./memory-manager.js');
const query = args.query as string;
const limit = (args.limit as number) || 8;
const type = args.type as string | undefined;
let results = searchMemories(query, limit);
if (type) results = results.filter(r => r.type === type);
logToolResult('memory_search', true, `${results.length} 条结果`);
return { success: true, results, total: results.length };
}
if (toolName === 'memory_add') {
const { addMemory } = await import('./memory-manager.js');
const type = args.type as 'fact' | 'preference' | 'rule';
const content = args.content as string;
const importance = (args.importance as number) || 5;
const tags = (args.tags as string[]) || [];
if (!content || content.length < 2) {
return { success: false, error: 'content 不能为空且至少 2 个字符' };
}
const entry = await addMemory({ type, content, importance, tags });
logToolResult('memory_add', true, `${type}: ${content.slice(0, 50)}`);
return { success: true, id: entry.id, type: entry.type, content: entry.content };
}
if (toolName === 'session_list') {
const bridge = window.metonaDesktop;
if (!bridge?.db) return { success: false, error: '桌面 API 不可用' };
const limit = (args.limit as number) || 20;
const search = (args.search as string) || '';
const sessions = await bridge.db.getAllSessions();
let filtered = sessions.map((s: any) => ({
id: s.id,
title: s.title,
model: s.model,
messageCount: 0, // 从 SQLite 获取的原始行不含 messages
createdAt: s.created_at,
updatedAt: s.updated_at
}));
if (search) {
filtered = filtered.filter((s: any) => s.title.toLowerCase().includes(search.toLowerCase()));
}
filtered.sort((a: any, b: any) => b.updatedAt - a.updatedAt);
filtered = filtered.slice(0, limit);
logToolResult('session_list', true, `${filtered.length} 个会话`);
return { success: true, sessions: filtered, total: filtered.length };
}
if (toolName === 'session_read') {
const bridge = window.metonaDesktop;
if (!bridge?.db) return { success: false, error: '桌面 API 不可用' };
const sessionId = args.session_id as string;
const maxMessages = (args.max_messages as number) || 50;
if (!sessionId) return { success: false, error: '缺少 session_id 参数' };
const session = await bridge.db.getSession(sessionId);
if (!session) return { success: false, error: `会话 ${sessionId} 不存在` };
const messages = await bridge.db.getMessages(sessionId);
const msgs = messages.slice(0, maxMessages).map((m: any) => ({
role: m.role,
content: m.content || '',
timestamp: m.created_at
}));
logToolResult('session_read', true, `${msgs.length} 条消息`);
return { success: true, session: { id: session.id, title: session.title, model: session.model }, messages: msgs, total: messages.length };
}
// run_command 走 workspace IPC
if (toolName === 'run_command') {
const command = args.command as string;
@@ -445,54 +575,27 @@ export async function executeTool(toolName: string, args: Record<string, unknown
export function getToolIcon(name: string): string {
const icons: Record<string, string> = {
read_file: '📄',
write_file: '✏️',
list_directory: '📁',
search_files: '🔍',
create_directory: '📂',
delete_file: '🗑️',
run_command: '💻',
move_file: '📦',
copy_file: '📋',
web_fetch: '🌐',
append_file: '',
edit_file: '✂️',
get_file_info: '️',
tree: '🌳',
download_file: '⬇️',
diff_files: '🔀',
replace_in_files: '🔄',
read_multiple_files: '📚',
git: '🔖',
compress: '🗜️',
web_search: '🔍'
read_file: '📄', write_file: '✏️', list_directory: '📁',
search_files: '🔍', create_directory: '📂', delete_file: '🗑️', run_command: '💻',
move_file: '📦', copy_file: '📋', web_fetch: '🌐', append_file: '',
edit_file: '✂️', get_file_info: '️', tree: '🌳', download_file: '⬇️',
diff_files: '🔀', replace_in_files: '🔄', read_multiple_files: '📚',
git: '🔖', compress: '🗜️', web_search: '🔍',
memory_search: '🧠', memory_add: '💾', session_list: '📋', session_read: '📖'
};
return icons[name] || '🔧';
}
export function formatToolName(name: string): string {
const names: Record<string, string> = {
read_file: '读取文件',
write_file: '写入文件',
list_directory: '列出目录',
search_files: '搜索文件',
create_directory: '创建目录',
delete_file: '删除文件',
run_command: '执行命令',
move_file: '移动文件',
copy_file: '复制文件',
web_fetch: '网页抓取',
append_file: '追加文件',
edit_file: '编辑文件',
get_file_info: '文件信息',
tree: '目录树',
download_file: '下载文件',
diff_files: '文件对比',
replace_in_files: '批量替换',
read_multiple_files: '批量读取',
git: 'Git 操作',
compress: '压缩/解压',
web_search: '联网搜索'
read_file: '读取文件', write_file: '写入文件', list_directory: '列出目录',
search_files: '搜索文件', create_directory: '创建目录', delete_file: '删除文件',
run_command: '执行命令', move_file: '移动文件', copy_file: '复制文件',
web_fetch: '网页抓取', append_file: '追加文件', edit_file: '编辑文件',
get_file_info: '文件信息', tree: '目录树', download_file: '下载文件',
diff_files: '文件对比', replace_in_files: '批量替换', read_multiple_files: '批量读取',
git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索',
memory_search: '搜索记忆', memory_add: '添加记忆', session_list: '会话列表', session_read: '读取会话'
};
return names[name] || name;
}