fix: 去掉流式空闲30s超时(大模型本地推理响应慢易误中止) fix: await filterEmbedModels 确保启动时模型能力徽章正常显示 fix: 剩余上下文改用 estimateTokens 估算而非 Ollama eval_count(KV Cache 导致虚高)
2242 lines
96 KiB
TypeScript
2242 lines
96 KiB
TypeScript
/**
|
||
* 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 { buildProjectIndex, buildIndexContext } from './context-indexer.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');
|
||
}
|
||
// 其他 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<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 命令。`);
|
||
|
||
// 反幻觉铁律
|
||
parts.push(`[反幻觉铁律 — 最高优先级,不可违反]
|
||
|
||
⚠️ 以下规则高于一切其他指令,违反将导致任务失败:
|
||
|
||
1. **禁止编造工具结果**:绝对不能在未调用工具的情况下声称"已搜索"、"已获取"、"已写入"、"已执行"。如果你没有调用过某个工具,就不能说使用了它。
|
||
2. **文件操作必须实际执行**:说"已写入文件"之前,必须先调用 write_file 并收到成功返回。说"已读取文件"之前,必须先调用 read_file。
|
||
3. **搜索必须实际执行**:说"搜索结果显示"、"根据搜索结果"之前,必须先调用 web_search。搜索结果的 snippet 不可信,必须用 web_fetch 获取完整内容后才能引用。
|
||
4. **信息不足时如实报告**:如果工具调用失败或返回了意外的结果,必须如实报告,不能编造替代信息。
|
||
5. **完成标志**:当所有必需的工具调用已经实际执行完毕,且获得了足够的信息后,才能给出最终回答。最终回答中不要编造"文件已生成"等声明——除非你真的调用了对应工具。
|
||
6. **用户上传的图片和文件已在当前消息中**:如果用户消息中包含图片附件标记(如 [已上传 N 张图片: ...])或文件标记(如 [文件: xxx]),说明这些资源已随消息作为附件提供,你可以直接"看到"和分析它们。**严禁再用 read_file / search_files 等工具去磁盘上查找这些已上传的图片和文件**——它们不在磁盘上,就在当前消息里。不要浪费轮次去做无意义的文件搜索。
|
||
7. **read_file 不能看图片**:read_file 返回的是文本或 base64 编码字符串,视觉模型无法处理。如果你需要分析图片内容,只有用户通过上传功能提供的图片才是可见的。不要尝试用 read_file mode=binary 去"读取"图片文件。
|
||
8. **禁止在 Plan 模式下为已上传的附件规划文件查找步骤**:如果用户消息中已经有图片或文件附件,执行计划的第一步应该是直接分析这些附件,而不是"搜索工作空间中的图片文件"或"查找相关文件"。
|
||
|
||
违反以上任何一条都是不可接受的错误。请逐条对照检查你的每一次回复。`);
|
||
|
||
// 日期
|
||
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}
|
||
⚠️ 以上是你需要完成的任务。即使上下文被压缩或清理,也必须记住并完成这个任务。`);
|
||
}
|
||
}
|
||
|
||
// ── 渐进式披露 — 项目索引(始终保留在上下文中)──
|
||
if (workspaceDir) {
|
||
try {
|
||
const projectIndex = await buildProjectIndex(workspaceDir);
|
||
const indexContext = buildIndexContext(projectIndex);
|
||
if (indexContext) {
|
||
systemPromptParts.push(indexContext);
|
||
logInfo('项目索引已注入系统提示词', `${projectIndex.tokenCount} tokens`);
|
||
}
|
||
} catch { /* 索引构建失败不影响主流程 */ }
|
||
}
|
||
}
|
||
|
||
// 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) : ['执行任务计划(详见上方描述)'];
|
||
}
|
||
|
||
/**
|
||
* P2-9 + P2-12: 中途幻觉检测 — 中英文双语规则,与 completion-gate 共用统一规则表
|
||
* 通过提取共享规则 + 添加英文 pattern,覆盖中英文模型输出
|
||
*/
|
||
function detectMidTaskHallucination(
|
||
content: string,
|
||
allToolRecords: Array<{ name: string }>,
|
||
): string | null {
|
||
const called = new Set(allToolRecords.map(r => r.name));
|
||
const lower = content.toLowerCase();
|
||
|
||
// 统一规则表:单一定义,mid-task 和 completion-gate 共用
|
||
for (const rule of HALLUCINATION_RULES) {
|
||
for (const pattern of rule.patterns) {
|
||
if (pattern.test(content)) {
|
||
const anyCalled = rule.requiredTools.some(t => called.has(t));
|
||
if (anyCalled) break; // 工具已调用,跳过此类检测
|
||
// 写后读豁免
|
||
if (rule.label.includes('读取') && called.has('write_file')) break;
|
||
return `${rule.label}操作但对应工具从未被实际调用(应调用 ${rule.requiredTools.join(' / ')})。`;
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** P2-12: 统一幻觉检测规则表(中英文双语),completion-gate.ts 通过 import 共享此表 */
|
||
interface HallucinationRule {
|
||
patterns: RegExp[];
|
||
requiredTools: string[];
|
||
label: string;
|
||
}
|
||
|
||
export const HALLUCINATION_RULES: HallucinationRule[] = [
|
||
// 文件写入 — 中英
|
||
{ patterns: [/已写入文件/, /已创建文件/, /文件已生成/, /已保存到/, /已成功创建/, /成功写入/,
|
||
/文件.*已.*创建/, /文件.*已.*写入/, /生成.*文件/, /写入.*工作空间/,
|
||
/(?:have|has)\s+(?:written|created|saved|generated)\s+(?:the\s+)?file/i,
|
||
/file\s+(?:has been|was)\s+(?:written|created|saved|generated)/i,
|
||
/I(?:'ve|\s+have)\s+(?:written|created|saved)/i],
|
||
requiredTools: ['write_file'], label: '写入/创建文件' },
|
||
// 文件编辑
|
||
{ patterns: [/已修改文件/, /已编辑文件/, /文件已更新/, /已替换.*内容/, /修改了.*文件/, /更新了.*文件/,
|
||
/(?:have|has)\s+(?:modified|edited|updated)\s+(?:the\s+)?file/i,
|
||
/I(?:'ve|\s+have)\s+(?:modified|edited|updated)\s+the\s+file/i],
|
||
requiredTools: ['edit_file', 'write_file'], label: '编辑/修改文件' },
|
||
// 文件删除
|
||
{ patterns: [/已删除文件/, /已移除文件/, /文件已清除/, /删除了.*文件/,
|
||
/(?:have|has)\s+(?:deleted|removed)\s+(?:the\s+)?file/i],
|
||
requiredTools: ['delete_file'], label: '删除文件' },
|
||
// 文件移动/复制
|
||
{ patterns: [/已移动文件/, /已复制文件/, /已重命名/, /文件.*已.*移动/, /文件.*已.*复制/,
|
||
/(?:have|has)\s+(?:moved|copied|renamed)\s+(?:the\s+)?file/i],
|
||
requiredTools: ['move_file', 'copy_file'], label: '移动/复制文件' },
|
||
// 目录创建
|
||
{ patterns: [/已创建了?(目录|文件夹)/,
|
||
/(?:have|has)\s+created\s+(?:a\s+)?(?:directory|folder)/i],
|
||
requiredTools: ['create_directory'], label: '创建目录' },
|
||
// 搜索/抓取网页
|
||
{ patterns: [/搜索(结果|到|显示).*(条|个)/, /根据搜索/, /抓取了.*网页/, /从.*搜索.*得到/,
|
||
/查询到/, /检索到/, /搜索到/, /网络.*显示/, /实时.*数据.*显示/,
|
||
/search\s+results\s+(?:show|indicate|suggest)/i, /according\s+to\s+(?:the\s+)?search/i,
|
||
/I\s+(?:found|discovered|searched)\s+(?:on|the\s+web|online)/i,
|
||
/based\s+on\s+(?:the\s+)?search\s+results/i],
|
||
requiredTools: ['web_search', 'web_fetch'], label: '搜索/抓取网页' },
|
||
// 浏览器
|
||
{ patterns: [/浏览器截图显示/, /打开网页.*看到/, /页面显示/, /浏览器.*打开/,
|
||
/点击了.*按钮/, /输入了.*文本/, /页面.*截图/,
|
||
/browser\s+(?:screenshot|shows|displays)/i, /opened\s+(?:the\s+)?(?:page|url|website)/i,
|
||
/I\s+(?:opened|navigated|browsed)\s+(?:to\s+)?(?:the\s+)?/i],
|
||
requiredTools: ['browser_open', 'browser_screenshot', 'browser_extract', 'browser_click', 'browser_type'], label: '浏览器操作' },
|
||
// 命令执行
|
||
{ patterns: [/运行.*命令/, /执行.*脚本/, /命令.*输出/, /运行结果/, /命令.*返回/, /执行.*结果/, /终端.*显示/,
|
||
/(?:ran|executed|run)\s+(?:the\s+)?command/i, /command\s+(?:output|result)/i,
|
||
/I(?:'ve|\s+have)\s+(?:run|executed)\s+(?:the\s+)?command/i],
|
||
requiredTools: ['run_command'], label: '执行命令' },
|
||
// Git 操作
|
||
{ patterns: [/已提交/, /已推送/, /已暂存/, /已创建分支/, /已合并/, /已克隆/,
|
||
/git.*commit/, /git.*push/, /git.*add/, /提交了.*代码/,
|
||
/(?:have|has)\s+(?:committed|pushed|staged|merged|cloned)/i,
|
||
/I(?:'ve|\s+have)\s+(?:committed|pushed|staged)/i],
|
||
requiredTools: ['git'], label: 'Git 操作' },
|
||
// 下载
|
||
{ patterns: [/已下载文件/, /下载完成/, /文件.*已.*下载/, /从.*下载/,
|
||
/(?:have|has)\s+downloaded\s+(?:the\s+)?file/i],
|
||
requiredTools: ['download_file'], label: '下载文件' },
|
||
// 压缩/解压
|
||
{ patterns: [/已压缩/, /已解压/, /压缩完成/, /解压完成/, /归档.*已.*创建/,
|
||
/(?:have|has)\s+(?:compressed|extracted|archived)/i],
|
||
requiredTools: ['compress'], label: '压缩/解压' },
|
||
// 记忆操作
|
||
{ patterns: [/已记住/, /已添加记忆/, /记忆已保存/, /已更新记忆/, /已删除记忆/,
|
||
/(?:have|has)\s+(?:remembered|memorized|saved\s+to\s+memory)/i],
|
||
requiredTools: ['memory'], label: '记忆操作' },
|
||
// 会话操作
|
||
{ patterns: [/历史会话.*显示/, /已读取.*会话/, /会话.*内容.*如下/,
|
||
/(?:have|has)\s+(?:read|listed)\s+(?:the\s+)?session/i],
|
||
requiredTools: ['session_list', 'session_read'], label: '会话操作' },
|
||
// 子代理
|
||
{ patterns: [/子代理.*完成/, /子任务.*已.*执行/, /spawn.*task.*完成/,
|
||
/sub[\s-]?agent\s+(?:has\s+)?(?:completed|finished)/i],
|
||
requiredTools: ['spawn_task'], label: '子代理委派' },
|
||
// 系统工具
|
||
{ patterns: [/计算.*结果.*为/, /哈希.*值.*为/, /UUID.*生成/, /随机.*生成.*为/,
|
||
/(?:calculated|computed|hashed)\s+(?:the\s+)?(?:result|value)/i],
|
||
requiredTools: ['calculator', 'hash', 'uuid', 'random'], label: '系统工具' },
|
||
];
|
||
|
||
/**
|
||
* 多步骤任务待办检测 — 解析用户请求中的动作动词,对比已完成的工具调用
|
||
* 返回尚未完成的任务描述列表
|
||
*/
|
||
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: '⚠️ 你的任务需要调用工具才能完成。请不要用文字描述"你会怎么做",而是直接调用对应的工具(web_search、write_file 等)来实际执行。如果你不确定调用哪个工具,查看可用工具列表。',
|
||
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: `⚠️ 你还有以下未完成的任务(来自用户原始请求):\n${pending.map((p, i) => ` ${i + 1}. ${p}`).join('\n')}\n\n请继续执行,完成所有任务后再给出最终回答。`,
|
||
ephemeral: true,
|
||
});
|
||
logInfo('待办追踪: 注入未完成任务提醒', pending.join(', '));
|
||
}
|
||
// 注:不再注入"全部完成"确认信号。工具调用数量≠任务完成度,
|
||
// 例如"删除3个文件"调用1次 delete_file 不能算完成。
|
||
// AI 应根据工具返回的实际结果自行判断任务是否完成。
|
||
}
|
||
|
||
// Token 预算警告
|
||
const remaining = ctx.maxLoops - ctx.loopCount + 1;
|
||
if (remaining <= 5 && remaining > 0) {
|
||
const warning = remaining <= 2
|
||
? `\n⚠️ CRITICAL: You have only ${remaining} iteration(s) left. Stop using tools and provide your final answer NOW.`
|
||
: `\n⚠️ WARNING: You have approximately ${remaining} iterations remaining. Start wrapping up and prepare your final answer.`;
|
||
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) };
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── 进度锚定 — 每 5 轮注入机器生成的工具调用摘要 ──
|
||
if (ctx.loopCount > 1 && ctx.loopCount % 5 === 0 && ctx.allToolRecords.length > 0) {
|
||
const recentRecords = ctx.allToolRecords.slice(-10);
|
||
const summary = recentRecords.map(r => {
|
||
const statusIcon = r.status === 'success' ? '✅' : r.status === 'error' ? '❌' : '🔄';
|
||
const argsPreview = r.arguments ? JSON.stringify(r.arguments).slice(0, 60) : '';
|
||
return `${statusIcon} ${r.name}(${argsPreview}${argsPreview.length >= 60 ? '…' : ''})`;
|
||
}).join('\n');
|
||
ctx.messages.push({
|
||
role: 'user',
|
||
content: `[进度锚点 #${ctx.loopCount}] 以下是你最近实际调用的工具(由系统记录,不可伪造):\n${summary}\n\n请基于以上真实记录继续。不要声称执行过未在列表中出现的操作。`,
|
||
ephemeral: true,
|
||
});
|
||
logInfo('进度锚点已注入', `第 ${ctx.loopCount} 轮, ${recentRecords.length} 条记录`);
|
||
}
|
||
|
||
// ── 通用重复调用检测:工具结果含去重信号 或 同工具连续成功 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 会看到
|
||
}
|
||
|
||
// ── 中途幻觉检测 — 每轮检查模型是否在无工具调用时声称完成了操作 ──
|
||
const lastAssistantMsg = [...ctx.messages].reverse().find(m => m.role === 'assistant');
|
||
if (lastAssistantMsg?.content) {
|
||
const midHallucination = detectMidTaskHallucination(lastAssistantMsg.content, ctx.allToolRecords);
|
||
if (midHallucination) {
|
||
logWarn(`中途幻觉检测: ${midHallucination}`);
|
||
ctx.messages.push({
|
||
role: 'user',
|
||
content: `⚠️ ${midHallucination}\n请实际调用对应工具来完成操作,不要用文字描述来替代。`,
|
||
ephemeral: true,
|
||
});
|
||
}
|
||
}
|
||
|
||
// 每 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;
|
||
}
|
||
|
||
// ── 正常终止: 模型停止调工具 → 模型认为任务完成 → 信任模型 ──
|
||
// Completion Gate 分级:阻断级检查(幻觉/注入)不通过则强制重新回答
|
||
// 咨询级检查(notThinking/contentQuality)仅记录日志
|
||
try {
|
||
const gateResult = await runCompletionGate(ctx);
|
||
if (!gateResult.passed) {
|
||
recordCompletionGate(false);
|
||
if (gateResult.critical) {
|
||
// 阻断级失败:强制模型重新回答
|
||
logWarn(`Completion Gate 🔴阻断: ${gateResult.reason}`);
|
||
// 检测是否所有待办任务已完成 — 若是,则只要求修改措辞;若待办中仍有被 Gate 拦截的操作,则明确要求调用工具
|
||
const firstUserMsg = ctx.messages.find(m => m.role === 'user');
|
||
const userText = firstUserMsg?.content || '';
|
||
const stillPending = detectPendingActions(userText, ctx.allToolRecords);
|
||
const gateReason = gateResult.reason || '';
|
||
let retryMsg: string;
|
||
if (stillPending.length === 0) {
|
||
retryMsg = `⚠️ 完成检查发现问题: ${gateReason}\n注意:你的所有工具调用已经实际完成。请只修改文字措辞消除上述问题,**绝对不要**调用任何新工具。直接重新输出修正后的文字即可。`;
|
||
} else if (gateReason.includes('写入') && stillPending.some(p => p.includes('写入'))) {
|
||
// 关键:模型声称写了文件但实际没调 write_file → 明确要求调用工具
|
||
retryMsg = `⚠️ 你还没有实际调用 write_file 工具写入文件,但回复中声称已写入。\n请**立即调用 write_file** 工具将内容写入工作空间,不要只在文字中描述。\n待完成任务: ${stillPending.join('、')}`;
|
||
} else if (gateReason.includes('搜索') && stillPending.some(p => p.includes('搜索'))) {
|
||
retryMsg = `⚠️ 你还没有实际调用 web_search 工具,但回复中声称已搜索。\n请**立即调用 web_search** 工具执行搜索。\n待完成任务: ${stillPending.join('、')}`;
|
||
} else {
|
||
retryMsg = `⚠️ 完成检查发现问题: ${gateReason}\n请修正后重新给出回答。待完成任务: ${stillPending.join('、')}`;
|
||
}
|
||
ctx.messages.push({
|
||
role: 'user',
|
||
content: retryMsg,
|
||
ephemeral: true,
|
||
});
|
||
transition(ctx, S.THINKING);
|
||
return;
|
||
}
|
||
// 咨询级失败:仅记录,不阻断
|
||
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
|
||
}
|
||
}
|