/** * Agent Engine - ReAct Agent Loop 核心引擎 * ReAct 模式: Thought → Action → Observation → Reflection * 状态机化架构 + Hook 系统 + Completion Gate + Plan Mode */ import { OllamaAPI } from '../api/ollama.js'; import { state, KEYS } from '../state/state.js'; import { executeTool, getEnabledToolDefinitions, needsConfirmation, initPlanTracker, getPlanTracker, formatPlanStatus, clearPlanTracker, } from './tool-registry.js'; import { search, formatMemoryContext, loadAllEntries } from './memory-service.js'; import { showToast } from '../components/toast.js'; import { logInfo, logWarn, logSuccess, logError, logToolStart, logToolResult, logAgentLoop, logModelResponse, logStreamProgress, resetStreamProgress } from './log-service.js'; import { getWorkspaceDirPath } from '../components/workspace-panel.js'; import { generateId } from '../utils/utils.js'; import { buildContext, estimateTokens, shouldAutoCompress, compressWithLLM, AUTO_COMPRESS_THRESHOLD, recordActualTokens } from './context-manager.js'; import { runCompletionGate } from './completion-gate.js'; import { executeHooks } from './hooks.js'; import { recordIteration, recordToolCall, recordCompletionGate, startSessionMetrics, endSessionMetrics } from './agent-metrics.js'; import type { OllamaMessage, OllamaStreamChunk, ToolCall, ToolResult, ToolCallRecord, ChatSession, LoopState, LoopContext, AgentMode, } from '../types.js'; const MAX_RETRIES = 2; // 工具错误自动重试次数 const MAX_MESSAGES = 200; // 上下文硬上限,超过则强制压缩(P0-4: 从500降到200,防止Ollama context超限) const INCREMENTAL_COMPRESS_AT = 120;// 增量压缩触发点:到达此数量后在 handleObserving 中触发 COMPRESSING /** ── LoopState 常量 ── */ const S = { INIT: 'INIT' as LoopState, THINKING: 'THINKING' as LoopState, PARSING: 'PARSING' as LoopState, EXECUTING: 'EXECUTING' as LoopState, OBSERVING: 'OBSERVING' as LoopState, REFLECTING: 'REFLECTING' as LoopState, COMPRESSING: 'COMPRESSING' as LoopState, TERMINATED: 'TERMINATED' as LoopState, } as const; /** ── 状态转换表 ── */ function getValidTransitions(state: LoopState): LoopState[] { switch (state) { case 'INIT': return ['THINKING', 'TERMINATED']; case 'THINKING': return ['PARSING', 'TERMINATED']; case 'PARSING': return ['EXECUTING', 'REFLECTING', 'THINKING', 'TERMINATED']; case 'EXECUTING': return ['OBSERVING', 'THINKING', 'TERMINATED']; case 'OBSERVING': return ['COMPRESSING', 'REFLECTING', 'THINKING', 'TERMINATED']; case 'REFLECTING': return ['THINKING', 'COMPRESSING', 'TERMINATED']; case 'COMPRESSING': return ['THINKING', 'REFLECTING', 'TERMINATED']; case 'TERMINATED': return []; } } /** 获取当前操作系统环境信息(用于系统提示词注入) */ function getOSEnvironment() { const isWin = navigator.platform?.toLowerCase().includes('win') || false; const isMac = navigator.platform?.toLowerCase().includes('mac') || false; const isLinux = !isWin && !isMac; const bridge = (window as any).metonaDesktop; const isDesktop = bridge?.isDesktop || false; const sys = bridge?.sys; const realHomeDir = sys?.homeDir || ''; const realShell = sys?.shell || ''; const realArch = sys?.arch || (navigator.platform || 'unknown'); const realUser = sys?.username || ''; return { os: isWin ? 'Windows' : isMac ? 'macOS' : 'Linux', platform: isDesktop ? 'Electron Desktop' : 'Browser', arch: realArch, shell: realShell || (isWin ? 'cmd.exe / PowerShell' : 'bash'), homeDir: realHomeDir || (isWin ? 'C:\\Users\\<用户名>' : '/home/<用户名>'), username: realUser, lineEnding: isWin ? 'CRLF (\\r\\n)' : 'LF (\\n)', pathSep: isWin ? '\\ (反斜杠)' : '/ (正斜杠)', }; } /** 每个工具返回给模型的最大字符数 */ const TOOL_MAX_RESULT_SIZE: Record = { web_fetch: 20000, web_search: 3000, read_file: 15000, read_multiple_files: 10000, list_directory: 5000, search_files: 5000, run_command: 10000, git: 5000, session_read: 15000, browser_extract: 10000, browser_evaluate: 8000, }; /** 始终可并行的只读/独立工具 */ const ALWAYS_PARALLEL = new Set([ 'read_file', 'list_directory', 'search_files', 'get_file_info', 'tree', 'web_search', 'browser_screenshot', 'browser_extract', 'browser_evaluate', 'memory_search', 'session_list', 'session_read', 'diff_files', 'git', 'datetime', 'calculator', 'random', 'uuid', 'json_format', 'hash', ]); /** P0-2: 检测工具调用之间是否存在路径依赖(写入 → 读取/写入同一路径) */ function hasPathDependency(call: ToolCall, prevBatch: ToolCall[]): boolean { const callPath = extractWritePath(call); if (!callPath) return false; // 非写操作,无依赖 for (const prev of prevBatch) { const prevPath = extractAffectedPath(prev); if (prevPath && pathsConflict(callPath, prevPath)) { logInfo(`串行化: ${prev.function.name}(${prevPath}) → ${call.function.name}(${callPath})`); return true; } } return false; } /** 提取工具写入的目标路径(会修改文件系统的操作) */ function extractWritePath(call: ToolCall): string | null { const args = call.function.arguments; switch (call.function.name) { case 'write_file': return String(args.path || ''); case 'edit_file': return String(args.path || ''); case 'delete_file': return String(args.path || ''); case 'create_directory': return String(args.path || ''); case 'move_file': return String(args.destination || args.source || ''); case 'copy_file': return String(args.destination || ''); case 'download_file': return String(args.destination || ''); case 'compress': return String(args.destination || args.path || ''); default: return null; } } /** 提取工具可能影响或读取的路径 */ function extractAffectedPath(call: ToolCall): string | null { const args = call.function.arguments; switch (call.function.name) { case 'read_file': return String(args.path || ''); case 'write_file': return String(args.path || ''); case 'edit_file': return String(args.path || ''); case 'delete_file': return String(args.path || ''); case 'list_directory': return String(args.path || ''); case 'search_files': return String(args.path || ''); case 'create_directory': return String(args.path || ''); case 'move_file': return String(args.source || ''); case 'copy_file': return String(args.source || ''); case 'get_file_info': return String(args.path || ''); case 'tree': return String(args.path || ''); case 'diff_files': return String(args.file1 || args.file2 || ''); case 'replace_in_files': return String(args.path || ''); case 'read_multiple_files': return null; // 多文件,难以精确判断 case 'download_file': return null; // 下载不依赖本地文件 case 'compress': return String(args.path || ''); default: return null; } } /** 两个路径是否冲突(相同、父子、重叠) */ function pathsConflict(a: string, b: string): boolean { if (!a || !b) return false; const na = a.replace(/\\/g, '/').replace(/\/+$/, ''); const nb = b.replace(/\\/g, '/').replace(/\/+$/, ''); // 完全相同或互为前缀(父子目录/文件) return na === nb || na.startsWith(nb + '/') || nb.startsWith(na + '/'); } /** P0-1: 判断错误是否为永久性(不应重试) */ function isPermanentError(errorMsg: string): boolean { const lower = errorMsg.toLowerCase(); const permanentPatterns = [ /ENOENT/i, /no such file/i, /not found/i, /文件不存在/i, /找不到/i, /EACCES/i, /permission denied/i, /权限/i, /EINVAL/i, /invalid/i, /参数.*错误/i, /EISDIR/i, /ENOTDIR/i, /ENOSPC/i, /disk.*full/i, /空间不足/i, /超出.*限制/i, /超过.*限制/i, /too large/i, /过大/i, /unsupported/i, /不支持/i, /not supported/i, /syntax error/i, /parse error/i, /格式错误/i, /禁止/i, /blocked/i, /拦截/i, /黑名单/i, ]; return permanentPatterns.some(p => p.test(lower)); } /** P1-7: 智能截断文本,优先在 JSON 边界处截断,避免破坏数据结构 */ function smartTruncate(text: string, maxLen: number): string { if (text.length <= maxLen) return text; // 尝试在最后一个完整 JSON 块处截断 const lastBrace = text.lastIndexOf('}', maxLen); const lastBracket = text.lastIndexOf(']', maxLen); const boundary = Math.max(lastBrace, lastBracket); if (boundary > maxLen * 0.5) { return text.slice(0, boundary + 1) + `\n... (${text.length - boundary - 1}字符 已截断)`; } // 回退到换行边界 const lastNewline = text.lastIndexOf('\n', maxLen); if (lastNewline > maxLen * 0.5) { return text.slice(0, lastNewline) + `\n... (${text.length - lastNewline}字符 已截断)`; } // 最后手段:硬截断 return text.slice(0, maxLen) + `... (${text.length - maxLen}字符 已截断)`; } /** 工具名白名单:用于文本解析兜底时过滤非法工具名 */ const VALID_TOOL_NAMES = new Set([ 'read_file', 'write_file', 'list_directory', 'search_files', 'create_directory', 'delete_file', 'run_command', 'move_file', 'copy_file', 'web_fetch', 'web_search', 'edit_file', 'get_file_info', 'tree', 'download_file', 'diff_files', 'replace_in_files', 'read_multiple_files', 'git', 'compress', 'memory', 'session_list', 'session_read', 'datetime', 'calculator', 'random', 'uuid', 'json_format', 'hash' ]); /** * 文本解析兜底:当模型没有通过 tool_calls 字段返回工具调用, * 而是在文本中写了 "Action: xxx" / "Action Input: {...}" 时, * 从文本中提取工具调用。 */ function parseToolCallsFromText(content: string): ToolCall[] { const calls: ToolCall[] = []; const actionRegex = /\*{0,2}Action:?\*{0,2}\s*(\w+)\s+[\r\n\s]*\*{0,2}Action\s*Input:?\*{0,2}\s*(\{[\s\S]*?\})/gi; let match; while ((match = actionRegex.exec(content)) !== null) { const toolName = match[1].trim(); const argsStr = match[2].trim(); if (!VALID_TOOL_NAMES.has(toolName)) continue; const TICK = String.fromCharCode(96); const tickJson = TICK + TICK + TICK + 'json'; const tick3 = TICK + TICK + TICK; try { let cleaned = argsStr .split(tickJson).join('') .split(tick3).join('') .trim(); const args = JSON.parse(cleaned); calls.push({ type: 'function', function: { name: toolName, arguments: args } }); } catch { try { let fixed = argsStr .replace(/'/g, '"') .replace(/,\s*}/g, '}') .replace(/,\s*]/g, ']') .split(tickJson).join('') .split(tick3).join('') .trim(); const args = JSON.parse(fixed); calls.push({ type: 'function', function: { name: toolName, arguments: args } }); } catch { logWarn("文本解析兜底: 工具 " + toolName + " 的参数 JSON 解析失败", argsStr.slice(0, 100)); } } } if (calls.length > 0) { logInfo("文本解析兜底: 从回复中提取到 " + calls.length + " 个工具调用", calls.map(c => c.function.name).join(', ')); } return calls; } const toolResultCache = new Map(); /** 工具缓存 TTL(毫秒),按工具类型设定。P1-6: default 从 Infinity 改为 60s */ const CACHE_TTL_MAP: Record = { web_search: 5 * 60_000, web_fetch: 10 * 60_000, read_file: 30 * 60_000, list_directory: 2 * 60_000, search_files: 2 * 60_000, browser_screenshot: 60_000, browser_extract: 5 * 60_000, get_file_info: 2 * 60_000, tree: 2 * 60_000, git: 30_000, // git status 30s 过期 datetime: 5_000, // 时间 5s 过期 session_list: 60_000, session_read: 60_000, diff_files: 2 * 60_000, default: 60_000, // 默认 60s,不再永久缓存 }; /** 检查缓存是否过期 */ function isCacheValid(toolName: string, timestamp: number): boolean { const ttl = CACHE_TTL_MAP[toolName] ?? CACHE_TTL_MAP.default; if (!isFinite(ttl)) return true; return Date.now() - timestamp < ttl; } /** 生成工具调用缓存 key */ function getToolCacheKey(name: string, args: Record): string { try { return name + '::' + JSON.stringify(args, Object.keys(args).sort()); } catch { return name + '::' + String(args); } } /** 检测当前轮次是否存在重复工具调用 */ function isDuplicateCall(call: ToolCall, allCalls: ToolCall[]): boolean { const callKey = getToolCacheKey(call.function.name, call.function.arguments); return allCalls.some((prev, idx) => { if (idx === allCalls.length - 1) return false; return getToolCacheKey(prev.function.name, prev.function.arguments) === callKey; }); } /** 格式化工具结果的通用默认路径 */ function formatDefaultToolResult(toolName: string, result: ToolResult): string { const clean: Record = {}; for (const [k, v] of Object.entries(result)) { if (k === 'success' || k === 'formatted' || k === 'content_type' || k === 'status' || k === 'length' || k === 'isDirectory') continue; clean[k] = v; } let json = JSON.stringify(clean); const maxLen = TOOL_MAX_RESULT_SIZE[toolName] || 15000; if (json.length > maxLen) json = json.slice(0, maxLen) + '\n... (已截断)'; return json; } /** * 格式化工具结果,生成模型友好的简洁表示 */ function formatToolResultForModel(toolName: string, result: ToolResult): string { if (!result.success) { return JSON.stringify({ success: false, error: result.error || '工具执行失败' }); } switch (toolName) { case 'web_search': { const raw = result.results as Array<{ title: string; url: string; snippet: string }> | undefined; if (!raw?.length) return JSON.stringify({ success: true, message: '未找到结果' }); const visible = Math.min(10, raw.length); const top = raw.slice(0, visible).map((r, i) => `[${i + 1}] ${r.title}\n URL: ${r.url}\n ${r.snippet}` ).join('\n\n'); const fetched = (result as any)._fetched as Array<{ url: string; title: string; content: string }> | undefined; const body = JSON.stringify({ success: true, query: result.query, total: result.total, shown: visible, results: top, }); if (fetched && fetched.length > 0) { return body + '\n\n' + fetched.map((f, i) => `\n=== 📄 已抓取 ${i + 1}/${fetched.length}: ${f.title} ===\n${f.content}\n` ).join('\n---\n'); } return body; } case 'web_fetch': { let content = (result.content as string) || ''; return JSON.stringify({ success: true, url: result.url, content }); } case 'read_file': { return JSON.stringify({ success: true, path: result.path, content: result.content, lines: result.lines, truncated: result.truncated, line_range: result.line_range }); } case 'read_multiple_files': { return JSON.stringify({ success: true, files: result.files, total: result.total }); } case 'list_directory': { return JSON.stringify({ success: true, path: result.path, entries: result.entries, total: result.total, truncated: result.truncated }); } case 'write_file': { return JSON.stringify({ success: true, path: result.path, bytesWritten: result.bytesWritten, created: result.created }); } case 'run_command': { return JSON.stringify({ success: true, stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode, duration: result.duration }); } case 'git': { return JSON.stringify({ success: true, action: result.action, output: result.output, branch: result.branch, files: result.files, commits: result.commits }); } case 'search_files': { return JSON.stringify({ success: true, query: result.query, total_matches: result.total_matches, total_files: result.total_files, results: result.results }); } case 'memory': { // 去重文字信号(触发 handleObserving 的⛔终止) if ((result as any).duplicate) { return `⚠️ 重复添加: ${(result as any).message || '相同内容已存在'}\n✅ 记忆操作已全部完成,不要再调用 memory 工具,直接输出最终回答。`; } // read_all / search 结果用可读文本格式,确保模型不会"看漏" if ((result as any).action === 'read_all') { const entries = ((result as any).entries || []) as Array<{ id: string; type: string; content: string; importance: number; tags: string[] }>; if (entries.length === 0) return '记忆为空,没有任何已保存的记忆条目。'; const grouped: Record = {}; for (const e of entries) { const t = e.type || 'fact'; (grouped[t] ||= []).push(e); } const lines: string[] = [`[记忆读取结果] 共 ${entries.length} 条记忆,按类型分组:`]; const typeLabels: Record = { rule: '规则(必须遵守)', preference: '偏好', fact: '事实' }; for (const [t, items] of Object.entries(grouped)) { lines.push(`\n--- ${typeLabels[t] || t} ---`); for (const e of items) { lines.push(` • [${e.type}] ${e.content}(重要性:${e.importance}, 标签: ${(e.tags || []).join(', ') || '无'})`); } } return lines.join('\n'); } if ((result as any).action === 'search') { const results = ((result as any).results || []) as Array<{ id: string; type: string; content: string; importance: number; score: number }>; if (results.length === 0) return '未找到匹配的记忆。'; const lines = [`[记忆搜索结果] 共 ${results.length} 条:`]; for (const r of results) { lines.push(` • [${r.type || 'fact'}] ${r.content}(重要性:${r.importance}, 匹配度:${(r.score || 0).toFixed(0)})`); } return lines.join('\n'); } // 其他 action(add/replace/remove)→ 保留完整 JSON,走 default 逻辑 return formatDefaultToolResult(toolName, result); } default: { return formatDefaultToolResult(toolName, result); } } } export interface AgentCallbacks { onThinking: (text: string) => void; onContent: (text: string) => void; onToolCallPrepare?: (call: ToolCall) => void; onToolCallStart: (call: ToolCall) => void; onToolCallResult: (name: string, result: ToolResult, call: ToolCall) => void; onToolCallError: (name: string, error: string, call: ToolCall) => void; onDone: (finalContent: string, toolRecords?: ToolCallRecord[], stats?: { eval_count?: number; prompt_eval_count?: number; total_duration?: number; ctx_tokens?: number }) => void; onConfirmTool: (call: ToolCall) => Promise; onNewIteration?: (toolCalls?: ToolCall[]) => void; /** Plan Mode: 计划已生成,等待用户确认 */ onPlanReady?: (plan: string, steps: string[]) => Promise; } /** P3-13: 带缓冲的轨迹保存 — 批量写入 SQLite,重试失败条目 */ const _traceBuffer: Array> = []; const TRACE_FLUSH_INTERVAL = 5000; // 5 秒刷新一次 const TRACE_MAX_RETRIES = 3; let _traceFlushTimer: ReturnType | null = null; async function flushTraces(): Promise { if (_traceBuffer.length === 0) return; const batch = _traceBuffer.splice(0, _traceBuffer.length); const bridge = window.metonaDesktop; if (!bridge?.db) return; for (const trace of batch) { const entry = { id: String(trace.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, error_pattern: trace.errorPattern || null, created_at: trace.createdAt }; // 重试最多 TRACE_MAX_RETRIES 次 for (let attempt = 0; attempt < TRACE_MAX_RETRIES; attempt++) { try { await bridge.db.saveTrace(entry); break; } catch { if (attempt < TRACE_MAX_RETRIES - 1) { await new Promise(r => setTimeout(r, 200 * (attempt + 1))); } } } } } /** 保存执行轨迹到 SQLite(缓冲写入) */ async function saveTrace(trace: Record): Promise { _traceBuffer.push(trace); if (!_traceFlushTimer) { _traceFlushTimer = setTimeout(() => { _traceFlushTimer = null; flushTraces(); }, TRACE_FLUSH_INTERVAL); } // 缓冲区超过 20 条立即刷新 if (_traceBuffer.length >= 20) { if (_traceFlushTimer) { clearTimeout(_traceFlushTimer); _traceFlushTimer = null; } flushTraces(); } } /** 强制刷新所有待写入的轨迹(在 Agent Loop 结束时调用) */ export async function flushAllTraces(): Promise { if (_traceFlushTimer) { clearTimeout(_traceFlushTimer); _traceFlushTimer = null; } await flushTraces(); } // ═══════════════════════════════════════════════════════════════ // P0-3: AbortController 集中管理 — 防泄漏、防竞态 // ═══════════════════════════════════════════════════════════════ /** 注册新的 AbortController,自动清理旧实例 */ function registerAbortController(): AbortController { const old = state.get(KEYS.ABORT_CONTROLLER); if (old) { try { old.abort(); } catch { /* ignore */ } } const ac = new AbortController(); state.set(KEYS.ABORT_CONTROLLER, ac); return ac; } /** 检查当前是否已被中止 */ function isAborted(): boolean { return state.get(KEYS.ABORT_CONTROLLER)?.signal.aborted === true; } /** 清理 AbortController(在 finally 块中调用) */ function cleanupAbortController(): void { const ac = state.get(KEYS.ABORT_CONTROLLER); if (ac) { try { ac.abort(); } catch { /* ignore */ } state.set(KEYS.ABORT_CONTROLLER, null); } } // ═══════════════════════════════════════════════════════════════ // Harness: 状态转换函数 // ═══════════════════════════════════════════════════════════════ function transition(ctx: LoopContext, newState: LoopState): void { const allowed = getValidTransitions(ctx.state); if (!allowed.includes(newState)) { logWarn(`非法的状态转换: ${ctx.state} → ${newState}(允许: ${allowed.join(', ')})`); // 容错:允许紧急终止 if (newState === S.TERMINATED) { ctx.state = S.TERMINATED; state.set('_loopState', ctx.state); return; } return; } ctx.state = newState; state.set('_loopState', ctx.state); } /** 持久化 LoopContext 到 state(用于暂停/恢复) */ function persistLoopContext(ctx: LoopContext): void { state.set('_loopContext', { sessionId: ctx.sessionId, loopCount: ctx.loopCount, maxLoops: ctx.maxLoops, totalEvalCount: ctx.totalEvalCount, totalPromptEvalCount: ctx.totalPromptEvalCount, allToolRecords: ctx.allToolRecords, startTime: ctx.startTime, }); } /** 从 state 恢复 LoopContext(暂未实现完整恢复,预留接口) */ function restoreLoopContext(_sessionId: string): Partial | null { return state.get | null>('_loopContext', null); } // ═══════════════════════════════════════════════════════════════ // Harness: 状态处理器 // ═══════════════════════════════════════════════════════════════ /** P2-11: 加载自定义文件(SOUL.md / AGENT.md / USER.md),返回 system prompt 片段 */ async function loadCustomFiles(workspaceDir: string, systemPromptParts: string[]): Promise { // SOUL.md — 工作空间优先,内置 fallback,不可压缩 let soulMdContent = ''; if (workspaceDir) { try { const r = await window.metonaDesktop?.workspace.readFile(workspaceDir.replace(/[\\/]+$/, '') + '/SOUL.md'); if (r?.success && r.content) { soulMdContent = r.content; logInfo('SOUL.md 已从工作空间加载', `${r.lines || 0} 行`); } } catch { /* ignore */ } } if (!soulMdContent) { try { const resp = await fetch('./SOUL.md'); if (resp.ok) { soulMdContent = await resp.text(); logInfo('SOUL.md 已从内置加载', `${soulMdContent.length} 字符`); } } catch { /* ignore */ } } if (soulMdContent) systemPromptParts.unshift(`[SOUL.md] ${soulMdContent}`); // AGENT.md — 工作空间优先,内置 fallback,Token 预算截断 let agentMdContent = ''; if (workspaceDir) { try { const r = await window.metonaDesktop?.workspace.readFile(workspaceDir.replace(/[\\/]+$/, '') + '/AGENT.md'); if (r?.success && r.content) { agentMdContent = r.content; logInfo('AGENT.md 已从工作空间加载', `${r.lines || 0} 行`); } } catch { /* ignore */ } } if (!agentMdContent) { try { const resp = await fetch('./AGENT.md'); if (resp.ok) { agentMdContent = await resp.text(); logInfo('AGENT.md 已从内置加载', `${agentMdContent.length} 字符`); } } catch { /* ignore */ } } if (agentMdContent) systemPromptParts.push(`[AGENT.md] ${truncateByTokenBudget(agentMdContent, 2000)}`); // USER.md — 仅工作空间,无内置 fallback if (workspaceDir) { try { const r = await window.metonaDesktop?.workspace.readFile(workspaceDir.replace(/[\\/]+$/, '') + '/USER.md'); if (r?.success && r.content) { systemPromptParts.push(`[USER.md] ${r.content}`); logInfo('USER.md 已从工作空间加载', `${r.lines || 0} 行`); } } catch { /* ignore */ } } } /** P2-11: 构建环境+日期固定提示词片段 */ function buildStandardPrompts(userContent: string): string[] { const parts: string[] = []; // 操作系统环境 const osInfo = getOSEnvironment(); parts.push(`[环境] 运行环境信息 操作系统: ${osInfo.os} 平台: ${osInfo.platform} 架构: ${osInfo.arch} Shell: ${osInfo.shell} 用户目录: ${osInfo.homeDir} 换行符: ${osInfo.lineEnding} 路径分隔符: ${osInfo.pathSep} ⚠️ 重要:必须使用与上述操作系统匹配的命令语法。 - 如果是 Windows,使用 CMD/PowerShell 命令(如 dir、type、findstr,路径用 \\) - 如果是 Linux/macOS,使用 Bash 命令(如 ls、cat、grep,路径用 /) - 严禁在 Windows 上执行 Linux 命令,严禁在 Linux 上执行 Windows 命令。`); // 日期 const now = new Date(); parts.push(`[日期] ${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日(此日期来自系统时钟,绝对可信。你的训练数据可能已过时,请以此日期为准构造所有搜索查询和时效性回答。绝对不要基于训练数据推断日期。)`); return parts; } /** * INIT 状态:构建系统提示词、准备上下文、压缩检测 */ async function handleInit( ctx: LoopContext, api: OllamaAPI, model: string, callbacks: AgentCallbacks, userContent: string, images: string[], historyMessages: OllamaMessage[], currentSession: ChatSession | null, ): Promise { // 新一轮对话,清空工具缓存 toolResultCache.clear(); const modelSupportsTools = state.get('modelSupportsTools', false); const tools = getEnabledToolDefinitions(); const useTools = tools.length > 0; ctx.messages.length = 0; const systemPromptParts: string[] = []; const workspaceDir = getWorkspaceDirPath(); // P2-11: 加载自定义文件 await loadCustomFiles(workspaceDir || '', systemPromptParts); // 注入记忆上下文 if (userContent) { try { const relevantMemories = await search(userContent, 6); if (relevantMemories.length > 0) { systemPromptParts.push(formatMemoryContext(relevantMemories)); } } catch { /* 记忆搜索失败不影响主流程 */ } } // 注入工作空间上下文 if (workspaceDir) { systemPromptParts.push(`【工作空间】 当前工作空间目录: ${workspaceDir} 你可以使用 run_command 工具在此目录下执行命令,所有命令通过工作空间进程管理执行,无超时限制。 文件操作工具(read_file、write_file 等)的相对路径基于此目录解析。`); // ── P1-8: Plan Mode 断点续传 — 检测上一轮未完成的计划 ── if (ctx.mode === 'plan') { const resumeData = state.get<{ steps: Array<{index:number;label:string;done:boolean}>; total: number; done: number; loopCount: number } | null>('_planResumeData', null); if (resumeData && resumeData.steps.length > 0 && resumeData.done < resumeData.total) { // 恢复追踪器状态 const { initPlanTracker } = await import('./tool-registry.js'); const steps = resumeData.steps.map(s => s.label); const tracker = initPlanTracker(steps); // 标记已完成的步骤 for (const s of resumeData.steps) { if (s.done) { tracker.steps[s.index - 1].done = true; tracker.done++; } } const { savePlanTracker } = await import('./tool-registry.js'); savePlanTracker(tracker); // 清空恢复数据,避免重复恢复 state.set('_planResumeData', null); // 注入恢复状态提示 systemPromptParts.push(`[Plan Mode 断点续传] 上一轮计划未完成,已自动恢复。当前进度: ${tracker.done}/${tracker.total}。已完成步骤将继续保持完成状态。继续执行剩余步骤,跳过已完成的步骤。`); logInfo(`Plan Mode 断点续传: ${tracker.done}/${tracker.total} 步骤已恢复,继续执行剩余 ${tracker.total - tracker.done} 步`); // 跳过"输出计划"约束,直接告诉 AI 继续执行 systemPromptParts.push(`⚠️ 计划已在上一轮生成并批准,现在直接继续执行剩余步骤。不需要重新输出计划。`); } else { // 清除可能的残留 state.set('_planResumeData', null); } } // ── Plan Mode 系统提示词 — 告知 AI plan_track 工具和追踪规则 ── if (ctx.mode === 'plan') { systemPromptParts.push(`[Plan Mode 执行规则] 你当前处于 Plan Mode(先规划后执行)。重要规则: **输出格式(必须严格遵守)**: 你的第一轮回复必须是一个清晰的执行计划,使用以下格式。不要输出其他无关内容。 ## 任务分析 [1-2 句话概述任务,展示你的理解] ## 执行计划 1. **步骤名称** — 工具: tool_name — 简要描述这一步做什么,完成后预期结果是什么 2. **步骤名称** — 无需工具 — 如果这一步只需推理/分析/总结,标注"无需工具"并描述分析要点 ...(根据任务复杂度,通常 2-6 步) ## 预期结果 [完成所有步骤后的最终产出] **关键规则**: - 需要调用工具的步骤:必须写"工具: xxx"(如 工具: web_search、工具: write_file) - 纯分析/推理/总结的步骤:写"无需工具",描述你要分析和思考的要点 - 禁止模糊描述如"分析需求"——即使是分析步骤,也要写清楚分析什么、关注什么 - 如果用户已上传图片或文件,第一步必须是分析附件(写"无需工具"即可,图片你直接能看到) - 如果整个任务完全不需要工具(如纯翻译、润色、总结对话),可以全部步骤都是"无需工具" - 输出计划后等待用户批准,**不要**在输出计划的同时调用工具 **计划批准后**: - 需要工具的步骤:调用对应工具完成任务 - 无需工具的步骤:直接基于已有信息给出分析结论 - 系统会自动追踪工具执行进度 - 所有步骤完成后直接给出最终回答。`); // 将用户原始任务描述固化到系统提示词中(不会被压缩或清理) const taskDesc = userContent?.slice(0, 200) || ''; if (taskDesc) { systemPromptParts.push(`[当前任务] 用户要求:${taskDesc} ⚠️ 以上是你需要完成的任务。即使上下文被压缩或清理,也必须记住并完成这个任务。`); } } } // P2-11: 注入标准固定提示词(环境 + 日期) systemPromptParts.push(...buildStandardPrompts(userContent)); const fullSystemPrompt = systemPromptParts.join('\n\n'); ctx.messages.push({ role: 'system', content: fullSystemPrompt }); // 保存完整系统提示词 const allSystemContent = ctx.messages .filter(m => m.role === 'system') .map(m => m.content) .join('\n\n'); state.set('_lastSystemPrompt', allSystemContent || fullSystemPrompt); // 添加历史消息(含工具调用和结果) for (const msg of historyMessages) { const ollamaMsg: OllamaMessage = { role: msg.role as 'user' | 'assistant' | 'system' | 'tool', content: msg.content, ...(msg.images?.length && { images: msg.images }), ...(msg.thinking && { thinking: msg.thinking }), ...(msg.tool_calls?.length && { tool_calls: msg.tool_calls }), ...(msg.tool_name && { tool_name: msg.tool_name }), ...(msg.compressed && { compressed: msg.compressed }), }; ctx.messages.push(ollamaMsg); } // 用户消息 const userMsg: OllamaMessage = { role: 'user', content: userContent || (images?.length ? (images.length > 1 ? `请分析这 ${images.length} 张图片` : '请分析这张图片') : ''), ...(images?.length && { images }) }; ctx.messages.push(userMsg); // 上下文窗口管理:滑动窗口 + 摘要压缩 + token 裁剪 const numCtx = state.get(KEYS.NUM_CTX, 131072); const contextResult = buildContext(ctx.messages, { maxTokens: numCtx, windowSize: 20 }); ctx.messages.length = 0; ctx.messages.push(...contextResult); // 自动压缩检测 if (shouldAutoCompress(ctx.messages, numCtx)) { logInfo(`自动上下文压缩触发: tokens≈${estimateTokens(ctx.messages.map(m => m.content || '').join(''))} > ${Math.floor(numCtx * AUTO_COMPRESS_THRESHOLD)} (50% of ${numCtx})`); const compressAC = state.get(KEYS.ABORT_CONTROLLER) || new AbortController(); try { const compressed = await compressWithLLM(ctx.messages, api as any, model, { abortController: compressAC }); if (compressed.length < ctx.messages.length || estimateTokens(compressed.map(m => m.content || '').join('')) < estimateTokens(ctx.messages.map(m => m.content || '').join(''))) { ctx.messages.length = 0; ctx.messages.push(...compressed); logSuccess(`自动上下文压缩完成: 剩余 ${ctx.messages.length} 条消息`); } } catch (err) { if ((err as Error).name === 'AbortError') throw err; logWarn('自动上下文压缩失败,继续使用当前上下文', (err as Error).message); } } logInfo(`ReAct Agent Loop 启动: ${model}`, `工具: ${useTools ? '开启' : '关闭'}, 模式: ${ctx.mode}, tokens≈${estimateTokens(ctx.messages.map(m => m.content || '').join(''))}`); // ── Plan Mode: 如果是 plan 模式,先注入计划提示 ── if (ctx.mode === 'plan' && ctx.loopCount === 0) { ctx.messages.push({ role: 'user' as const, content: `[Plan Mode] 请按照 Plan Mode 执行规则中指定的格式输出执行计划。不要直接执行工具调用,先输出计划等待用户批准。`, ephemeral: true, }); } return; } /** 按 token 预算截断文本(约 1.5 中文字/token, 4 英文字符/token) */ function truncateByTokenBudget(text: string, maxTokens: number): string { const estimated = estimateTokens(text); if (estimated <= maxTokens) return text; // 按比例截取 const ratio = maxTokens / estimated; const cutAt = Math.floor(text.length * ratio * 0.9); // 保守一点 return text.slice(0, cutAt) + '\n\n... (已截断以控制 Token 预算)'; } /** 从 Plan Mode 输出中提取步骤列表(兼容多种模型格式) */ function extractPlanSteps(content: string): string[] { const steps: string[] = []; // 策略1: 精确匹配 "## 执行计划" 区块中的编号行 const planSection = content.match(/##\s*执行计划\s*\n([\s\S]*?)(?=\n##|\n---|\n\*\*关键|$)/); if (planSection) { const lines = planSection[1].split('\n'); for (const line of lines) { // 匹配: 1. **步骤名** — 工具: xxx — 描述 const match = line.match(/^\d+[\.\)、]\s*(?:\*\*)?(.+?)(?:\*\*)?(?:\s*[—\-]\s*)/); if (match) { const step = match[0].trim(); if (step.length >= 5 && step.length <= 200) { steps.push(step); continue; } } // 回退: 匹配任何编号行 const looseMatch = line.match(/^\d+[\.\)、]\s+(.+)/); if (looseMatch) { const step = looseMatch[1].trim(); if (step.length >= 5 && step.length <= 200) { steps.push(step); } } } } // 策略2: 回退 — 全局匹配编号行(松动匹配,接受"无需工具"的步骤) if (steps.length === 0) { const stepRegex = /(?:^|\n)\s*(?:\d+[\.\)、]\s*|[-*]\s+)(.+)/g; const filterPatterns = [/^\*\*.*阶段.*\*\*/, /\*\*第[一二三]|\*\*Phase/, /^```/, /^<\/?/, /^Stage/i]; let match; while ((match = stepRegex.exec(content)) !== null) { const step = match[1].trim(); if (step.length < 5 || step.length > 200) continue; if (filterPatterns.some(p => p.test(step))) continue; steps.push(step); } } return steps.length > 0 ? steps.slice(0, 8) : ['执行任务计划(详见上方描述)']; } /** * 多步骤任务待办检测 — 解析用户请求中的动作动词,对比已完成的工具调用 * 返回尚未完成的任务描述列表 */ function detectPendingActions( userText: string, allToolRecords: Array<{ name: string }>, ): string[] { const called = new Set(allToolRecords.map(r => r.name)); const pending: string[] = []; const txt = userText.toLowerCase(); // 动作 → 必需工具映射 const ACTION_MAP: Array<{ keywords: string[]; tools: string[]; label: string }> = [ { keywords: ['搜索', '查找', '查一下', '搜一下', 'search', '检索', '查询'], tools: ['web_search'], label: '搜索/查找信息' }, { keywords: ['抓取', 'fetch', '获取内容', '抓取全文'], tools: ['web_fetch'], label: '抓取网页详细内容' }, { keywords: ['生成', '写入', '创建文件', '保存', '输出.*文件', '放到.*工作空间', '写.*文件', '生成.*文件', '存入'], tools: ['write_file'], label: '创建/写入文件' }, { keywords: ['运行', '执行', 'run', '启动', '编译'], tools: ['run_command'], label: '执行命令/脚本' }, { keywords: ['下载', 'download'], tools: ['download_file'], label: '下载文件' }, { keywords: ['提交', 'commit', '推送', 'push', '暂存', 'add'], tools: ['git'], label: 'Git 操作' }, { keywords: ['删除', '移除', 'delete', 'remove'], tools: ['delete_file'], label: '删除文件' }, { keywords: ['移动', 'move', '重命名', 'rename'], tools: ['move_file'], label: '移动/重命名文件' }, { keywords: ['复制', 'copy'], tools: ['copy_file'], label: '复制文件' }, { keywords: ['压缩', '解压', 'compress', 'zip', 'tar'], tools: ['compress'], label: '压缩/解压' }, { keywords: ['打开网页', '浏览器', 'browser', '截图'], tools: ['browser_open', 'browser_screenshot', 'browser_extract'], label: '浏览器操作' }, ]; for (const action of ACTION_MAP) { const mentioned = action.keywords.some(kw => new RegExp(kw, 'i').test(txt)); if (mentioned) { const completed = action.tools.some(t => called.has(t)); if (!completed) { pending.push(action.label); } } } return pending; } /** 获取用户请求中提到的全部动作标签(不论是否完成),用于全部完成时生成确认信号 */ function detectAllMentionedActions(userText: string): string[] { const txt = userText.toLowerCase(); const mentioned: string[] = []; const ACTION_MAP: Array<{ keywords: string[]; label: string }> = [ { keywords: ['搜索', '查找', '查一下', '搜一下', 'search', '检索', '查询'], label: '搜索/查找' }, { keywords: ['生成', '写入', '创建文件', '保存', '写.*文件', '生成.*文件', '存入', '输出'], label: '写入文件' }, { keywords: ['运行', '执行', 'run', '启动', '编译'], label: '执行命令' }, { keywords: ['下载', 'download'], label: '下载文件' }, { keywords: ['提交', 'commit', '推送', 'push'], label: 'Git 操作' }, { keywords: ['删除', '移除', 'delete', 'remove'], label: '删除文件' }, { keywords: ['移动', 'move', '重命名', 'rename'], label: '移动/重命名' }, { keywords: ['复制', 'copy'], label: '复制文件' }, { keywords: ['压缩', '解压', 'compress', 'zip', 'tar'], label: '压缩/解压' }, { keywords: ['打开网页', '浏览器', 'browser', '截图'], label: '浏览器操作' }, ]; for (const action of ACTION_MAP) { if (action.keywords.some(kw => new RegExp(kw, 'i').test(txt))) { mentioned.push(action.label); } } return mentioned; } /** * 通用工具结果核验 — 对所有写类工具执行后验证 * 读取/搜索类工具已有返回结果作为验证,此处只核验会产生副作用的操作 */ const TOOLS_NEED_VERIFY = new Set([ 'write_file', 'edit_file', 'delete_file', 'create_directory', 'move_file', 'copy_file', 'download_file', ]); async function verifyToolResult( toolName: string, args: Record, result: Record, ): Promise { if (!TOOLS_NEED_VERIFY.has(toolName) || !result.success) return; const bridge = window.metonaDesktop; if (!bridge?.isDesktop) return; try { switch (toolName) { case 'write_file': { const path = String(args.path || ''); const expected = String(args.content || ''); const read = await bridge.tool.execute('read_file', { path, mode: 'text' }); if (read.success) { const actual = String(read.content || ''); if (actual.length === 0) { logWarn(`核验 write_file: ${path} 内容为空`); } else if (Math.abs(actual.length - expected.length) > expected.length * 0.5) { logWarn(`核验 write_file: ${path} 期望 ${expected.length}B 实际 ${actual.length}B`); } else { logInfo(`核验 write_file ✅: ${path} (${actual.length}B)`); } } break; } case 'edit_file': { const path = String(args.path || ''); const newText = String(args.new_text || ''); const read = await bridge.tool.execute('read_file', { path, mode: 'text' }); if (read.success) { const actual = String(read.content || ''); if (!actual.includes(newText)) { logWarn(`核验 edit_file: ${path} 不包含期望的新内容`); } else { logInfo(`核验 edit_file ✅: ${path}`); } } break; } case 'delete_file': { const path = String(args.path || ''); const info = await bridge.tool.execute('get_file_info', { path }); if (info.success) { logWarn(`核验 delete_file: ${path} 仍然存在,删除未生效`); } else { logInfo(`核验 delete_file ✅: ${path} 已不存在`); } break; } case 'create_directory': { const path = String(args.path || ''); const info = await bridge.tool.execute('get_file_info', { path }); if (info.success && (info as any).type === 'directory') { logInfo(`核验 create_directory ✅: ${path}`); } else { logWarn(`核验 create_directory: ${path} 未成功创建`); } break; } case 'move_file': { const src = String(args.source || ''); const dest = String(args.destination || ''); const [srcInfo, destInfo] = await Promise.all([ bridge.tool.execute('get_file_info', { path: src }), bridge.tool.execute('get_file_info', { path: dest }), ]); if (srcInfo.success) { logWarn(`核验 move_file: 源文件 ${src} 仍然存在`); } else if (destInfo.success) { logInfo(`核验 move_file ✅: ${src} → ${dest}`); } else { logWarn(`核验 move_file: ${src}→${dest} 源和目标均不存在`); } break; } case 'copy_file': { const dest = String(args.destination || ''); const destInfo = await bridge.tool.execute('get_file_info', { path: dest }); if (destInfo.success) { logInfo(`核验 copy_file ✅: ${dest} (${(destInfo as any).size}B)`); } else { logWarn(`核验 copy_file: 目标 ${dest} 不存在`); } break; } case 'download_file': { const dest = String(args.destination || ''); const info = await bridge.tool.execute('get_file_info', { path: dest }); if (info.success && (info as any).size > 0) { logInfo(`核验 download_file ✅: ${dest} (${(info as any).size}B)`); } else { logWarn(`核验 download_file: ${dest} 不存在或为空`); } break; } } } catch { /* 核验失败不阻塞主流程 */ } } /** * THINKING 状态:调用 LLM 流式 */ async function handleThinking( ctx: LoopContext, api: OllamaAPI, model: string, callbacks: AgentCallbacks, ): Promise { ctx.loopCount++; logAgentLoop(ctx.loopCount, ctx.maxLoops); ctx.content = ''; ctx.thinking = ''; ctx.toolCalls.length = 0; // ── Plan Mode 执行进度注入 — 每次思考前展示当前状态 ── if (ctx.mode === 'plan' && ctx.loopCount >= 1) { const tracker = getPlanTracker(); if (tracker.active && tracker.steps.length > 0) { const status = formatPlanStatus(); if (status) { ctx.messages.push({ role: 'user', content: status, ephemeral: true }); } } } // ── 任务感知 — 检测用户请求需要工具但模型尚未行动 ── if (ctx.loopCount === 2 && ctx.allToolRecords.length === 0) { // 从用户第一条消息中检测是否需要工具 const firstUserMsg = ctx.messages.find(m => m.role === 'user'); const userText = (firstUserMsg?.content || '').toLowerCase(); const actionVerbs = ['搜索', '查找', '查', '写入', '创建', '生成', '运行', '执行', '打开', '抓取', '获取', '下载', '提交', '推送', '克隆', '读取', '删除', '移动', '复制', '压缩', 'search', 'find', 'write', 'create', 'run', 'execute', 'fetch', 'download', 'clone']; const needsTools = actionVerbs.some(v => userText.includes(v)) && userText.length > 20; if (needsTools) { ctx.messages.push({ role: 'user', content: '请直接调用工具完成任务,不要只描述做法。', ephemeral: true, }); logInfo('任务感知: 注入工具调用提醒'); } } // ── 多步骤任务待办追踪 — 用户一句话里有多个动作时,提醒未完成的 ── if (ctx.loopCount >= 2 && ctx.allToolRecords.length > 0) { const firstUserMsg = ctx.messages.find(m => m.role === 'user'); const userText = firstUserMsg?.content || ''; const pending = detectPendingActions(userText, ctx.allToolRecords); if (pending.length > 0) { ctx.messages.push({ role: 'user', content: `还有任务未完成: ${pending.join('、')}`, ephemeral: true, }); logInfo('待办追踪', pending.join(', ')); } // 注:不再注入"全部完成"确认信号。工具调用数量≠任务完成度, // 例如"删除3个文件"调用1次 delete_file 不能算完成。 // AI 应根据工具返回的实际结果自行判断任务是否完成。 } // Token 预算警告 const remaining = ctx.maxLoops - ctx.loopCount + 1; if (remaining <= 3 && remaining > 0) { const warning = `剩余 ${remaining} 轮,尽快完成。`; ctx.messages.push({ role: 'user', content: warning, ephemeral: true }); } const abortController = registerAbortController(); // ── 流式超时保护 ── const STREAM_TIMEOUT_MS = state.get('streamTimeout', 300_000); // 总超时 300s let totalTimer: ReturnType | null = null; if (STREAM_TIMEOUT_MS > 0) { totalTimer = setTimeout(() => { logWarn(`流式总超时 (${STREAM_TIMEOUT_MS / 1000}s),中止本轮`); abortController.abort(); }, STREAM_TIMEOUT_MS); } let progressTimer: ReturnType | null = null; try { const streamStartTime = Date.now(); let lastLoggedLen = 0; let lastContentTime = streamStartTime; resetStreamProgress(); progressTimer = setInterval(() => { const elapsed = Date.now() - streamStartTime; const grew = ctx.content.length - lastLoggedLen; const sec = Math.round(elapsed / 1000); if (grew > 0) { lastLoggedLen = ctx.content.length; lastContentTime = Date.now(); logStreamProgress(`流式输出中… ${ctx.content.length} 字 / ${sec}s${ctx.toolCalls.length > 0 ? ` [${ctx.toolCalls.length} 个工具调用]` : ''}`); } else if (ctx.content.length > 0) { const idleSec = Math.round((Date.now() - lastContentTime) / 1000); if (idleSec >= 10) { logStreamProgress(`⚠️ ${idleSec}s 无新内容,当前 ${ctx.content.length} 字`); } } else { logStreamProgress(`⏳ 等待模型响应… ${sec}s`); } }, 15000); await api.chatStream( { model, messages: ctx.messages, stream: true, think: state.get('thinkEnabled', false), options: { num_ctx: state.get(KEYS.NUM_CTX, 131072), temperature: state.get('temperature', 0.7) }, ...(getEnabledToolDefinitions().length > 0 && { tools: getEnabledToolDefinitions() }) }, (chunk: OllamaStreamChunk) => { if (chunk.message?.thinking) { ctx.thinking += chunk.message.thinking; callbacks.onThinking(ctx.thinking); } if (chunk.message?.content) { ctx.content += chunk.message.content; callbacks.onContent(ctx.content); } if (chunk.eval_count) { ctx.loopEvalCount = chunk.eval_count; } if (chunk.prompt_eval_count) { ctx.loopPromptEvalCount = chunk.prompt_eval_count; } if (chunk.total_duration) { ctx.loopInferenceNs = chunk.total_duration; } if (chunk.message?.tool_calls?.length) { for (const tc of chunk.message.tool_calls) { if (tc.function?.name) { let parsedArgs: Record = {}; if (tc.function.arguments) { if (typeof tc.function.arguments === 'string') { try { parsedArgs = JSON.parse(tc.function.arguments); } catch { parsedArgs = {}; } } else if (typeof tc.function.arguments === 'object') { parsedArgs = tc.function.arguments as Record; } } const isNewTool = !ctx.toolCalls.some(existing => existing.function.name === tc.function.name); if (isNewTool && callbacks.onToolCallPrepare) { callbacks.onToolCallPrepare({ type: 'function', function: { name: tc.function.name, arguments: parsedArgs } }); } ctx.toolCalls.push({ type: 'function', function: { name: tc.function.name, arguments: parsedArgs } }); } else if (ctx.toolCalls.length > 0) { const last = ctx.toolCalls[ctx.toolCalls.length - 1]; if (tc.function?.arguments) { let chunkArgs: Record | null = null; if (typeof tc.function.arguments === 'string') { try { chunkArgs = JSON.parse(tc.function.arguments); } catch { /* ignore */ } } else if (typeof tc.function.arguments === 'object') { chunkArgs = tc.function.arguments as Record; } if (chunkArgs) { Object.assign(last.function.arguments, chunkArgs); } } } } } }, abortController ); // 累加 token 统计 ctx.totalEvalCount += ctx.loopEvalCount; ctx.totalPromptEvalCount += ctx.loopPromptEvalCount; ctx.totalInferenceNs += ctx.loopInferenceNs; state.set('_currentEvalCount', ctx.totalEvalCount); // Token 校准 try { if (ctx.loopEvalCount > 0 || ctx.loopPromptEvalCount > 0) { const estimatedThisLoop = estimateTokens(ctx.messages.map(m => m.content || '').join('')); if (estimatedThisLoop > 0) { recordActualTokens(ctx.loopPromptEvalCount, ctx.loopEvalCount, estimatedThisLoop); } } } catch { /* ignore */ } // 重置本轮计数器 ctx.loopEvalCount = 0; ctx.loopPromptEvalCount = 0; ctx.loopInferenceNs = 0; if (totalTimer) { clearTimeout(totalTimer); totalTimer = null; } if (progressTimer) clearInterval(progressTimer as unknown as number); transition(ctx, S.PARSING); } catch (err) { if (totalTimer) { clearTimeout(totalTimer); totalTimer = null; } if (progressTimer) clearInterval(progressTimer as unknown as number); if (abortController.signal.aborted) { logInfo('流式调用已中止'); if (ctx.allToolRecords.length > 0) { state.set('_abortToolRecords', ctx.allToolRecords); } throw err; } logError('流式调用异常', (err as Error).message); if (ctx.content || ctx.thinking) { ctx.messages.push({ role: 'assistant', content: ctx.content || '(模型响应异常)', ...(ctx.thinking && { thinking: ctx.thinking }) }); } callbacks.onDone(ctx.content || '(模型响应异常,已自动中断)', ctx.allToolRecords.length > 0 ? ctx.allToolRecords : undefined, makeStats(ctx)); transition(ctx, S.TERMINATED); } } /** * PARSING 状态:解析模型输出,提取工具调用和文本兜底 */ async function handleParsing(ctx: LoopContext): Promise { // 保存 assistant 消息 const assistantMsg: OllamaMessage = { role: 'assistant', content: ctx.content, ...(ctx.thinking && { thinking: ctx.thinking }) }; if (ctx.toolCalls.length > 0) { assistantMsg.tool_calls = ctx.toolCalls; } ctx.messages.push(assistantMsg); logModelResponse(ctx.content.length, ctx.toolCalls.length); // 文本解析兜底 if (ctx.toolCalls.length === 0 && getEnabledToolDefinitions().length > 0) { const parsedCalls = parseToolCallsFromText(ctx.content); if (parsedCalls.length > 0) { ctx.toolCalls.push(...parsedCalls); } } if (ctx.toolCalls.length > 0) { transition(ctx, S.EXECUTING); } else { transition(ctx, S.REFLECTING); } } /** * EXECUTING 状态:执行工具(按批次并行) */ async function handleExecuting( ctx: LoopContext, callbacks: AgentCallbacks, ): Promise { // 跨轮次去重检测 const currentLoopKeys = ctx.toolCalls.map(c => getToolCacheKey(c.function.name, c.function.arguments)).sort(); const currentKeysStr = JSON.stringify(currentLoopKeys); const prevKeysStr = JSON.stringify([...ctx.prevLoopSuccessKeys].sort()); if (currentKeysStr === prevKeysStr && currentLoopKeys.length > 0) { const hasFailedInPrev = ctx.allToolRecords .filter(r => ctx.prevLoopSuccessKeys.includes(getToolCacheKey(r.name, r.arguments))) .some(r => r.status !== 'success'); if (!hasFailedInPrev) { logWarn('检测到连续两轮工具调用完全相同且全部成功,注入提醒而非终止'); // ── 取消工具卡片:卡片已在 THINKING 阶段设为 pending,这里需要收尾 ── for (const call of ctx.toolCalls) { callbacks.onToolCallError(call.function.name, '系统跳过(与上一轮完全相同)', call); } ctx.messages.push({ role: 'user', content: '⚠️ 检测到你连续调用了与上一轮完全相同的工具。请基于已有结果给出最终回答,或者调用不同的工具获取新信息。如果任务已完成,请给出最终回答。' }); transition(ctx, S.THINKING); return; } logInfo('检测到重复调用但上一轮有失败,允许重试'); } // ── 工具并行执行:智能批次划分(P0修复:检测实际数据依赖)── const batches: ToolCall[][] = []; let currentBatch: ToolCall[] = []; for (const call of ctx.toolCalls) { if (currentBatch.length === 0) { currentBatch.push(call); } else if (ALWAYS_PARALLEL.has(call.function.name)) { currentBatch.push(call); } else if (hasPathDependency(call, currentBatch)) { // 存在路径依赖 → 新开批次(串行化) batches.push(currentBatch); currentBatch = [call]; } else { currentBatch.push(call); } } if (currentBatch.length > 0) batches.push(currentBatch); if (batches.length > 1) { logInfo(`工具并行执行: ${ctx.toolCalls.length} 个工具 → ${batches.length} 批次(首批 ${batches[0].length} 个并行)`); } /** 执行单个工具(含智能重试:永久错误立即返回,瞬态错误最多重试 MAX_RETRIES 次) */ const executeSingleTool = async (call: ToolCall): Promise<[ToolCallRecord, string | null]> => { const cacheKey = getToolCacheKey(call.function.name, call.function.arguments); if (isDuplicateCall(call, ctx.toolCalls)) { logWarn(`跳过重复工具调用: ${call.function.name}`); const cached = toolResultCache.get(cacheKey); if (cached) { return [{ name: call.function.name, arguments: call.function.arguments, result: cached.result, status: 'success' as const, timestamp: Date.now() }, null]; } } const cachedEntry = toolResultCache.get(cacheKey); if (cachedEntry && isCacheValid(call.function.name, cachedEntry.timestamp)) { logInfo(`工具缓存命中: ${call.function.name}`); return [{ name: call.function.name, arguments: call.function.arguments, result: cachedEntry.result, status: 'success' as const, timestamp: Date.now() }, null]; } if (cachedEntry) { toolResultCache.delete(cacheKey); } // P2-10 修复:queueMicrotask 替代 rAF,在隐藏窗口下不会降频 await new Promise(r => { queueMicrotask(r); }); callbacks.onToolCallStart(call); logToolStart(call.function.name, JSON.stringify(call.function.arguments)); // ── pre_tool Hook(可阻断)── const hookResult = await executeHooks('pre_tool', ctx, { toolName: call.function.name, toolArgs: call.function.arguments }); if (!hookResult.allPassed) { const reasons = hookResult.results.filter(r => !r.passed).map(r => r.message).join('; '); logWarn(`pre_tool Hook 阻断: ${call.function.name}`, reasons); return [{ name: call.function.name, arguments: call.function.arguments, result: { success: true, error: '', message: reasons }, status: 'success' as const, timestamp: Date.now() }, null]; } await new Promise(r => { queueMicrotask(r); }); if (needsConfirmation(call.function.name)) { const confirmed = await callbacks.onConfirmTool(call); if (!confirmed) { logWarn(`工具取消: ${call.function.name}`); const cancelResult = { success: false, error: '用户取消了操作' }; return [{ name: call.function.name, arguments: call.function.arguments, result: cancelResult, status: 'cancelled' as const, timestamp: Date.now() }, null]; } } // ── P0-1 修复:区分瞬态错误和永久错误 ── let lastError = ''; for (let retry = 0; retry <= MAX_RETRIES; retry++) { // 重试前检查中止信号 if (isAborted()) { return [{ name: call.function.name, arguments: call.function.arguments, result: { success: false, error: '用户中止' }, status: 'cancelled' as const, timestamp: Date.now() }, null]; } try { const result = await executeTool(call.function.name, call.function.arguments) .catch(err => ({ success: false, error: err?.message || String(err) }) as ToolResult); logToolResult(call.function.name, result.success, result.success ? undefined : result.error); return [{ name: call.function.name, arguments: call.function.arguments, result, status: result.success ? 'success' as const : 'error' as const, timestamp: Date.now() }, result.success ? cacheKey : null]; } catch (err) { lastError = (err as Error).message; // 永久性错误不重试:文件不存在、权限拒绝、路径不可达 if (isPermanentError(lastError)) { logWarn(`工具永久错误(不重试): ${call.function.name}`, lastError); return [{ name: call.function.name, arguments: call.function.arguments, result: { success: false, error: lastError }, status: 'error' as const, timestamp: Date.now() }, null]; } if (retry < MAX_RETRIES) { // 瞬态错误:指数退避重试 const delay = 500 * Math.pow(2, retry); logWarn(`工具重试 ${retry + 1}/${MAX_RETRIES}: ${call.function.name}(${delay}ms 后)`, lastError); await new Promise(r => setTimeout(r, delay)); continue; } logError(`工具执行失败(已达最大重试): ${call.function.name}`, lastError); return [{ name: call.function.name, arguments: call.function.arguments, result: { success: false, error: lastError }, status: 'error' as const, timestamp: Date.now() }, null]; } } return [{ name: call.function.name, arguments: call.function.arguments, result: { success: false, error: lastError }, status: 'error' as const, timestamp: Date.now() }, null]; }; // 按批次执行 for (const batch of batches) { if (state.get(KEYS.ABORT_CONTROLLER)?.signal.aborted) { callbacks.onDone(ctx.content, ctx.allToolRecords.length > 0 ? ctx.allToolRecords : undefined, makeStats(ctx)); transition(ctx, S.TERMINATED); return; } const results = await Promise.all(batch.map(call => executeSingleTool(call))); for (const [record, cacheKey] of results) { ctx.allToolRecords.push(record); ctx.messages.push({ role: 'tool', tool_name: record.name, content: formatToolResultForModel(record.name, record.result!) }); // ── 读数据类工具结果加硬提示,防止模型忽略实际数据去用系统提示词编造 ── if (record.status === 'success') { const nudges: Record = { memory: '以上 tool 消息是 memory 工具返回的实际记忆数据。严格基于这些数据回答,不要把系统提示词里的内容当成记忆列出来。', session_read: '以上 tool 消息是 session_read 返回的实际历史会话内容。严格基于这些数据回答,不要编造。', session_list: '以上 tool 消息是 session_list 返回的实际会话列表。严格基于这些数据回答,不要编造。', spawn_task: '以上 tool 消息是 spawn_task 子代理返回的实际执行结果。严格基于这些数据回答,不要编造。', }; const nudge = nudges[record.name]; if (nudge) { ctx.messages.push({ role: 'user', content: `⚠️ ${nudge}` }); } } if (cacheKey) toolResultCache.set(cacheKey, { result: record.result!, timestamp: Date.now() }); // 记录度量 recordToolCall(record.name, record.status, Date.now() - record.timestamp); // ── 通用工具结果核验 — 所有写类工具执行后验证 ── if (record.status === 'success') { verifyToolResult(record.name, record.arguments, record.result!); } // ── post_tool Hook ── executeHooks('post_tool', ctx, { toolName: record.name, toolArgs: record.arguments, toolResult: record.result! }); if (record.status === 'success') { callbacks.onToolCallResult(record.name, record.result!, batch.find(c => c.function.name === record.name)!); } else if (record.status === 'cancelled') { callbacks.onToolCallError(record.name, '用户取消', batch.find(c => c.function.name === record.name)!); } else { callbacks.onToolCallError(record.name, record.result?.error || '执行失败', batch.find(c => c.function.name === record.name)!); } } } transition(ctx, S.OBSERVING); } /** * OBSERVING 状态:收集结果、裁剪旧消息、更新去重键 */ async function handleObserving( ctx: LoopContext, currentSession: ChatSession | null, ): Promise { // 中止检查 if (isAborted()) return; // 硬限制工具消息数量 — 保留最近 40 条 { const toolIndices: number[] = []; for (let i = 0; i < ctx.messages.length; i++) { if (ctx.messages[i].role === 'tool') toolIndices.push(i); } if (toolIndices.length > 40) { const toRemove = toolIndices.slice(0, toolIndices.length - 40); for (let i = toRemove.length - 1; i >= 0; i--) { ctx.messages.splice(toRemove[i], 1); } logInfo(`工具消息裁剪: ${toRemove.length} 条旧结果已移除 (保留最近 40 条)`); } } // 更新跨轮去重 ctx.prevLoopSuccessKeys = ctx.allToolRecords .filter(r => r.status === 'success') .map(r => getToolCacheKey(r.name, r.arguments)); // 保存本轮工具调用 ctx.prevToolCalls = [...ctx.toolCalls]; // 记录本轮迭代度量 recordIteration(ctx); // ── post_iteration Hook ── executeHooks('post_iteration', ctx, { iterationIndex: ctx.loopCount }); // ── P1-7: 智能工具结果截断 — 保留 JSON 结构,2000 字符软上限 ── if (ctx.loopCount > 10 && ctx.loopCount % 3 === 0) { const truncateBefore = ctx.messages.length - 15; for (let i = 0; i < ctx.messages.length && i < truncateBefore; i++) { const m = ctx.messages[i]; if (m.role === 'tool' && m.content && m.content.length > 2000) { ctx.messages[i] = { ...m, content: smartTruncate(m.content, 2000) }; } } } // ── 通用重复调用检测:工具结果含去重信号 或 同工具连续成功 2+ 次 ── const lastToolContents = ctx.messages.filter(m => m.role === 'tool').slice(-3).map(m => m.content || ''); const hasDedupSignal = lastToolContents.some(c => c.includes('⚠️ 重复添加') || c.includes('已跳过') || c.includes('duplicate')); const recentSuccess = ctx.allToolRecords.filter(r => r.status === 'success').slice(-2); const allSameTool = recentSuccess.length >= 2 && recentSuccess.every(r => r.name === recentSuccess[0].name); if (hasDedupSignal || allSameTool) { ctx.messages.push({ role: 'user', content: `⛔ 检测到重复的工具调用${hasDedupSignal ? '(系统已跳过重复内容)' : ''}。「${recentSuccess[0]?.name || 'memory'}」操作已经完成,立即输出最终回答,绝对不要再调用任何工具。`, }); logInfo(`重复调用检测: ${recentSuccess[0]?.name || 'memory'}${hasDedupSignal ? ' (去重信号)' : ''},注入⛔终止信号`); ctx.maxLoops = Math.min(ctx.maxLoops, ctx.loopCount + 1); // 不要 transition 到 REFLECTING,直接在这里就已经注入了信号,下一轮 THINKING 会看到 } // 每 10 轮清理累积的 ephemeral 消息 // 但如果上下文使用率已过高(>70%),跳过清理——压缩即将触发, // 此时清理会导致压缩 LLM 看不到 Plan Mode 进度等关键状态信息 if (ctx.loopCount > 1 && ctx.loopCount % 10 === 0) { const numCtx = state.get(KEYS.NUM_CTX, 131072); const usageRatio = numCtx > 0 ? estimateTokens(ctx.messages.map(m => m.content || '').join('')) / numCtx : 0; if (usageRatio > 0.7) { logInfo(`ephemeral 清理跳过: 上下文使用率 ${(usageRatio * 100).toFixed(0)}%, 压缩即将触发`); } else { let removed = 0; ctx.messages = ctx.messages.filter(m => { if (m.ephemeral) { removed++; return false; } return true; }); if (removed > 0) logInfo(`ephemeral 清理: ${removed} 条临时消息已移除`); } } // ── P0-4: 增量压缩 — 消息数达到 120 条时提前触发压缩,防止 Ollama context 超限 ── if (ctx.messages.length >= INCREMENTAL_COMPRESS_AT) { logWarn(`增量压缩触发: ${ctx.messages.length} 条消息 > ${INCREMENTAL_COMPRESS_AT}`); transition(ctx, S.COMPRESSING); return; } transition(ctx, S.REFLECTING); } /** * REFLECTING 状态:反思 * * 核心原则:模型不调工具 = 模型认为完成了。信任模型的判断。 * 仅在明确的异常情况下介入(空响应、Plan Mode 确认、超最大轮次)。 */ async function handleReflecting( ctx: LoopContext, callbacks: AgentCallbacks, currentSession: ChatSession | null, ): Promise { // 中止检查 if (isAborted()) return; // ── 异常1: 空响应 + 有工具历史 → 模型可能困惑 → 注入提示 ── if (ctx.toolCalls.length === 0 && !ctx.content.trim() && ctx.loopCount > 1 && ctx.allToolRecords.length > 0) { logWarn('模型返回空内容(有未处理的工具结果),注入继续提示'); // 精准移除最后一条 assistant 消息(而非盲目 pop) for (let i = ctx.messages.length - 1; i >= 0; i--) { if (ctx.messages[i].role === 'assistant') { ctx.messages.splice(i, 1); break; } } ctx.messages.push({ role: 'user', content: '请根据上面的工具调用结果继续回答。如果需要更多信息,可以继续调用工具。如果已有足够信息,请给出最终回答。' }); transition(ctx, S.THINKING); return; } // ── Plan Mode: 第一轮回复 → 检测是否为计划 → 等待用户确认 ── if (ctx.mode === 'plan' && ctx.loopCount === 1 && ctx.toolCalls.length === 0 && ctx.content.length > 50) { const isPlanLike = /计划|步骤|step|plan|思路|方案|流程/.test(ctx.content.slice(0, 200)); if (isPlanLike && callbacks.onPlanReady) { const steps = extractPlanSteps(ctx.content); const approved = await callbacks.onPlanReady(ctx.content, steps); if (!approved) { ctx.planRetries++; if (ctx.planRetries >= 3) { logWarn('Plan Mode: 达到最大重试次数 (3),强制进入执行模式'); ctx.messages.push({ role: 'user', content: '已达到最大计划重试次数。请基于当前计划直接开始执行任务。', ephemeral: true, }); transition(ctx, S.THINKING); return; } logInfo('Plan Mode: 用户拒绝计划,重新规划'); ctx.messages.push({ role: 'user', content: '请基于反馈重新规划。调整你的方案后再次输出计划。', ephemeral: true, }); transition(ctx, S.THINKING); return; } logInfo('Plan Mode: 用户批准计划,开始执行'); // 初始化 Plan Tracker,追踪每个步骤的完成状态 if (steps.length > 0) { initPlanTracker(steps); logInfo('Plan Tracker 已启动', `${steps.length} 个步骤`); } // 移除 Plan Mode 的"不要执行工具"约束消息,替换为执行指令 // 从后往前找到并移除 Plan Mode 提示(ephemeral 标记) for (let i = ctx.messages.length - 1; i >= 0; i--) { if (ctx.messages[i].ephemeral && ctx.messages[i].content?.includes('不要直接执行工具调用')) { ctx.messages.splice(i, 1); break; } } ctx.messages.push({ role: 'user', content: '计划已批准。现在开始执行——请直接调用工具(web_search、write_file 等)来完成每一步。不要描述计划,直接行动。', ephemeral: true, }); transition(ctx, S.THINKING); return; } // 非计划回复(如简单问答)→ 跳过 Plan Mode,直接完成 if (!isPlanLike) { logInfo('Plan Mode: 回复非计划内容,直接完成'); } } // ── 异常2: 超出最大轮次 → 强制终止 ── if (ctx.loopCount >= ctx.maxLoops) { logWarn('ReAct Agent Loop 达到最大迭代次数限制'); callbacks.onDone(ctx.content || '(达到最大工具调用次数限制)', ctx.allToolRecords, makeStats(ctx)); transition(ctx, S.TERMINATED); return; } // ── 本轮调用了工具 → 模型需要处理工具结果 → 继续循环 ── if (ctx.toolCalls.length > 0) { transition(ctx, S.THINKING); return; } // ── 正常终止: 模型停止调工具 → 信任模型的判断 ── try { const gateResult = await runCompletionGate(ctx); if (!gateResult.passed) { recordCompletionGate(false); logWarn(`Completion Gate: ${gateResult.reason}(仅记录,不阻断)`); } else { recordCompletionGate(true); } } catch { /* Gate 异常不影响主流程 */ } logInfo('模型停止工具调用 → ReAct Agent Loop 结束'); // ── 保存 Plan Mode 最终进度到状态,供下一轮 Loop 注入历史 ── if (ctx.mode === 'plan') { const tracker = getPlanTracker(); if (tracker.active && tracker.steps.length > 0) { state.set('_lastPlanStatus', formatPlanStatus()); } } clearPlanTracker(); // ── 自动记忆提取(异步,不阻塞用户看到回复)── const abortController = state.get(KEYS.ABORT_CONTROLLER); if (!abortController?.signal.aborted) { const _api = state.get(KEYS.API); const _model = state.get('_defaultModel', ''); const msgsForMemory = ctx.messages .filter(m => m.role === 'user' || m.role === 'assistant') .map(m => ({ role: m.role, content: m.content || '' })); // 使用 setTimeout 延迟执行,确保 onDone 先触发,用户先看到回复 setTimeout(() => { import('./memory-service.js').then(({ extractAndSaveMemories }) => { extractAndSaveMemories(msgsForMemory, _api, _model).catch(() => {}); }).catch(() => {}); }, 500); } callbacks.onDone(ctx.content || '(模型未返回内容)', ctx.allToolRecords.length > 0 ? ctx.allToolRecords : undefined, makeStats(ctx)); transition(ctx, S.TERMINATED); } /** * COMPRESSING 状态:触发上下文压缩 */ async function handleCompressing( ctx: LoopContext, api: OllamaAPI, model: string, ): Promise { const numCtx = state.get(KEYS.NUM_CTX, 131072); if (shouldAutoCompress(ctx.messages, numCtx)) { logInfo('COMPRESSING: 上下文压缩触发'); const compressAC = state.get(KEYS.ABORT_CONTROLLER) || new AbortController(); try { const compressed = await compressWithLLM(ctx.messages, api as any, model, { abortController: compressAC }); if (compressed.length < ctx.messages.length || estimateTokens(compressed.map(m => m.content || '').join('')) < estimateTokens(ctx.messages.map(m => m.content || '').join(''))) { ctx.messages.length = 0; ctx.messages.push(...compressed); logSuccess('COMPRESSING: 完成'); } } catch (err) { if ((err as Error).name === 'AbortError') throw err; logWarn('COMPRESSING: 失败', (err as Error).message); } } transition(ctx, S.REFLECTING); } // ═══════════════════════════════════════════════════════════════ // Harness: 主入口 — 状态机循环 // ═══════════════════════════════════════════════════════════════ function makeStats(ctx: LoopContext) { return { eval_count: ctx.totalEvalCount || undefined, prompt_eval_count: ctx.totalPromptEvalCount || undefined, total_duration: ctx.totalInferenceNs || undefined, ctx_tokens: estimateTokens(ctx.messages.map(m => m.content || '').join('')), // 实际上下文占用 }; } export async function runAgentLoop( userContent: string, images: string[], historyMessages: OllamaMessage[], callbacks: AgentCallbacks ): Promise { const api = state.get(KEYS.API); const model = state.get('_defaultModel', ''); const currentSession = state.get(KEYS.CURRENT_SESSION); const sessionId = currentSession?.id || 'unknown'; const mode: AgentMode = state.get('agentMode', 'auto'); if (!api || !model) { showToast('请先选择模型', 'error'); return; } // ── 构建 LoopContext ── const ctx: LoopContext = { state: S.INIT, sessionId, loopCount: 0, maxLoops: state.get('maxTurns', 85), content: '', thinking: '', toolCalls: [], allToolRecords: [], messages: [], totalEvalCount: 0, totalPromptEvalCount: 0, totalInferenceNs: 0, loopEvalCount: 0, loopPromptEvalCount: 0, loopInferenceNs: 0, prevLoopSuccessKeys: [], prevToolCalls: [], mode, planRetries: 0, startTime: Date.now(), }; state.set('_loopState', S.INIT); persistLoopContext(ctx); startSessionMetrics(sessionId, model); // Plan Mode 激活时注册 plan_track 工具 const { setPlanModeActive } = await import('./tool-registry.js'); setPlanModeActive(mode === 'plan'); // ── 状态机主循环 ── try { // Phase 1: INIT await handleInit(ctx, api, model, callbacks, userContent, images, historyMessages, currentSession); transition(ctx, S.THINKING); // Phase 2-7: THINKING → PARSING → EXECUTING → OBSERVING → REFLECTING → (COMPRESSING) → loop while (ctx.state !== S.TERMINATED) { // ── 看门狗 — 全局超时熔断(可通过设置 loopWatchdogMs 配置,0=禁用)── // 默认 30 分钟,用户强调不要随意加超时限制 const WATCHDOG_MS = state.get('loopWatchdogMs', 1_800_000); if (WATCHDOG_MS > 0 && Date.now() - ctx.startTime > WATCHDOG_MS) { logWarn(`看门狗触发: Agent Loop 运行超过 ${WATCHDOG_MS / 60000} 分钟,强制终止`); ctx.messages.push({ role: 'user', content: `⚠️ 任务运行时间超过 ${Math.round(WATCHDOG_MS / 60000)} 分钟限制,系统自动终止。请简化任务或分批执行。`, ephemeral: true, }); callbacks.onDone(ctx.content || '(看门狗超时终止)', ctx.allToolRecords.length > 0 ? ctx.allToolRecords : undefined, makeStats(ctx)); transition(ctx, S.TERMINATED); break; } // ── 上下文硬上限 — 消息数超阈值强制压缩 ── if (ctx.messages.length > MAX_MESSAGES) { logWarn(`上下文硬上限触发: ${ctx.messages.length} 条消息 > ${MAX_MESSAGES},强制压缩`); transition(ctx, S.COMPRESSING); ctx.state = S.COMPRESSING; // 跳过 transition 验证直接进入(压缩态允许) state.set('_loopState', S.COMPRESSING); // 同步全局状态 } // 检查中止信号 if (isAborted()) { logInfo('ReAct Agent Loop 已中止'); if (ctx.allToolRecords.length > 0) { state.set('_abortToolRecords', ctx.allToolRecords); } throw new DOMException('Aborted', 'AbortError'); } // Token 感知的动态迭代预算 const numCtx = state.get(KEYS.NUM_CTX, 131072); const usageRatio = estimateTokens(ctx.messages.map(m => m.content || '').join('')) / numCtx; if (usageRatio > 0.8 && (ctx.maxLoops - ctx.loopCount) > 3) { const newMax = ctx.loopCount + 3; logWarn(`上下文使用率 ${(usageRatio * 100).toFixed(0)}%, 限制剩余迭代为 ${newMax - ctx.loopCount} 轮(原 ${ctx.maxLoops - ctx.loopCount} 轮)`); ctx.maxLoops = newMax; } // 非首轮迭代:通知 UI if (ctx.loopCount > 0 && callbacks.onNewIteration) { callbacks.onNewIteration(ctx.prevToolCalls.length > 0 ? ctx.prevToolCalls : undefined); } // 状态分发 switch (ctx.state) { case S.THINKING: await handleThinking(ctx, api, model, callbacks); // handleThinking 内部会 transition 到 PARSING 或 TERMINATED break; case S.PARSING: await handleParsing(ctx); // handleParsing 内部会 transition 到 EXECUTING 或 REFLECTING break; case S.EXECUTING: await handleExecuting(ctx, callbacks); // handleExecuting 内部会 transition 到 OBSERVING 或 THINKING 或 TERMINATED break; case S.OBSERVING: await handleObserving(ctx, currentSession); // handleObserving 内部会 transition 到 REFLECTING break; case S.REFLECTING: await handleReflecting(ctx, callbacks, currentSession); // handleReflecting 内部会 transition 到 THINKING / COMPRESSING / TERMINATED break; case S.COMPRESSING: await handleCompressing(ctx, api, model); // handleCompressing 内部会 transition 到 REFLECTING break; default: logWarn(`未知状态: ${ctx.state},强制终止`); transition(ctx, S.TERMINATED); } persistLoopContext(ctx); } } catch (err) { if ((err as Error).name === 'AbortError') { // 中止是正常的,但仍需通知 UI 清理状态 callbacks.onDone(ctx.content || '(已中止)', ctx.allToolRecords.length > 0 ? ctx.allToolRecords : undefined, makeStats(ctx)); } else { logError('Agent Loop 异常', (err as Error).message); callbacks.onDone(ctx.content || '(Agent Loop 异常终止)', ctx.allToolRecords.length > 0 ? ctx.allToolRecords : undefined, makeStats(ctx)); } transition(ctx, S.TERMINATED); } finally { // ── P1-8: Plan Mode 断点续传 — 保存完整追踪器到 session,支持跨轮次恢复 ── if (ctx.mode === 'plan') { const tracker = getPlanTracker(); if (tracker.active && tracker.steps.length > 0) { // 保存可恢复的完整追踪器(与 formatted status 共存) const resumeData = { steps: tracker.steps.map(s => ({ index: s.index, label: s.label, done: s.done })), total: tracker.total, done: tracker.done, loopCount: ctx.loopCount, savedAt: Date.now(), }; state.set('_planResumeData', resumeData); state.set('_lastPlanStatus', formatPlanStatus()); } } clearPlanTracker(); endSessionMetrics(); cleanupAbortController(); flushAllTraces(); // P3-13: 确保所有轨迹写入 SQLite } }