Files
metona-ollama-desktop/src/renderer/services/agent-engine.ts
T
thzxx 4c0405dc95 chore: 版本号 0.14.1 → 0.14.2
refactor: 移除抗幻觉全部功能(幻觉检测/反幻觉提示词/Completion Gate阻断)
perf: 精简提示词(去掉进度锚点/项目索引,简化任务感知/预算警告措辞)
fix: 模型能力徽章启动时不显示(await filterEmbedModels)
fix: 剩余上下文改用 estimateTokens 估算(Ollama KV Cache 导致虚高)
fix: 去掉流式空闲30s超时(大模型本地推理响应慢)
fix: 能力介绍类回复不再触发幻觉误判(已随抗幻觉移除一并解决)
2026-06-28 13:54:11 +08:00

2043 lines
83 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, number> = {
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<string, { result: ToolResult; timestamp: number }>();
/** 工具缓存 TTL(毫秒),按工具类型设定。P1-6: default 从 Infinity 改为 60s */
const CACHE_TTL_MAP: Record<string, number> = {
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, unknown>): 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<string, unknown> = {};
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<string, typeof entries> = {};
for (const e of entries) {
const t = e.type || 'fact';
(grouped[t] ||= []).push(e);
}
const lines: string[] = [`[记忆读取结果] 共 ${entries.length} 条记忆,按类型分组:`];
const typeLabels: Record<string, string> = { 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');
}
// 其他 actionadd/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<boolean>;
onNewIteration?: (toolCalls?: ToolCall[]) => void;
/** Plan Mode: 计划已生成,等待用户确认 */
onPlanReady?: (plan: string, steps: string[]) => Promise<boolean>;
}
/** P3-13: 带缓冲的轨迹保存 — 批量写入 SQLite,重试失败条目 */
const _traceBuffer: Array<Record<string, any>> = [];
const TRACE_FLUSH_INTERVAL = 5000; // 5 秒刷新一次
const TRACE_MAX_RETRIES = 3;
let _traceFlushTimer: ReturnType<typeof setTimeout> | null = null;
async function flushTraces(): Promise<void> {
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<string, any>): Promise<void> {
_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<void> {
if (_traceFlushTimer) { clearTimeout(_traceFlushTimer); _traceFlushTimer = null; }
await flushTraces();
}
// ═══════════════════════════════════════════════════════════════
// P0-3: AbortController 集中管理 — 防泄漏、防竞态
// ═══════════════════════════════════════════════════════════════
/** 注册新的 AbortController,自动清理旧实例 */
function registerAbortController(): AbortController {
const old = state.get<AbortController | null>(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<AbortController | null>(KEYS.ABORT_CONTROLLER)?.signal.aborted === true;
}
/** 清理 AbortController(在 finally 块中调用) */
function cleanupAbortController(): void {
const ac = state.get<AbortController | null>(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<LoopContext> | null {
return state.get<Partial<LoopContext> | null>('_loopContext', null);
}
// ═══════════════════════════════════════════════════════════════
// Harness: 状态处理器
// ═══════════════════════════════════════════════════════════════
/** P2-11: 加载自定义文件(SOUL.md / AGENT.md / USER.md),返回 system prompt 片段 */
async function loadCustomFiles(workspaceDir: string, systemPromptParts: string[]): Promise<void> {
// 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<void> {
// 新一轮对话,清空工具缓存
toolResultCache.clear();
const modelSupportsTools = state.get<boolean>('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<number>(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<AbortController | null>(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<string, unknown>,
result: Record<string, unknown>,
): Promise<void> {
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<void> {
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<number>('streamTimeout', 300_000); // 总超时 300s
let totalTimer: ReturnType<typeof setTimeout> | null = null;
if (STREAM_TIMEOUT_MS > 0) {
totalTimer = setTimeout(() => {
logWarn(`流式总超时 (${STREAM_TIMEOUT_MS / 1000}s),中止本轮`);
abortController.abort();
}, STREAM_TIMEOUT_MS);
}
let progressTimer: ReturnType<typeof setInterval> | 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<boolean>('thinkEnabled', false),
options: {
num_ctx: state.get<number>(KEYS.NUM_CTX, 131072),
temperature: state.get<number>('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<string, unknown> = {};
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<string, unknown>;
}
}
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<string, unknown> | 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<string, unknown>;
}
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<void> {
// 保存 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<void> {
// 跨轮次去重检测
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<void>(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<void>(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<AbortController | null>(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<string, string> = {
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<void> {
// 中止检查
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<number>(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<void> {
// 中止检查
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<AbortController | null>(KEYS.ABORT_CONTROLLER);
if (!abortController?.signal.aborted) {
const _api = state.get<OllamaAPI>(KEYS.API);
const _model = state.get<string>('_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<void> {
const numCtx = state.get<number>(KEYS.NUM_CTX, 131072);
if (shouldAutoCompress(ctx.messages, numCtx)) {
logInfo('COMPRESSING: 上下文压缩触发');
const compressAC = state.get<AbortController | null>(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<void> {
const api = state.get<OllamaAPI>(KEYS.API);
const model = state.get<string>('_defaultModel', '');
const currentSession = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
const sessionId = currentSession?.id || 'unknown';
const mode: AgentMode = state.get<AgentMode>('agentMode', 'auto');
if (!api || !model) {
showToast('请先选择模型', 'error');
return;
}
// ── 构建 LoopContext ──
const ctx: LoopContext = {
state: S.INIT,
sessionId,
loopCount: 0,
maxLoops: state.get<number>('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<number>('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<number>(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
}
}