Files
metona-ollama-desktop/src/renderer/services/agent-engine.ts
T
thzxx 9cb6c243a7 refactor: use typed window.metonaDesktop instead of (window as any)
Replaced all '(window as any).metonaDesktop' with the properly typed
'window.metonaDesktop' across mcp-client.ts, skill-manager.ts,
agent-engine.ts, and log-service.ts. The MetonaDesktopAPI type is
already declared in types.d.ts, so the 'as any' cast was unnecessary
and bypassed type checking.
2026-04-24 14:03:34 +08:00

917 lines
38 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 核心引擎 (v4.0)
* ReAct 模式: Thought → Action → Observation → Reflection
* 支持任务复杂度评估、自动规划、错误重试、执行轨迹记录
*/
import { OllamaAPI } from '../api/ollama.js';
import { ChatDB } from '../db/chat-db.js';
import { state, KEYS } from '../state/state.js';
import {
executeTool,
getEnabledToolDefinitions,
needsConfirmation
} from './tool-registry.js';
import { searchMemories, buildMemoryContext, markMemoryUsed, isMemoryEnabled } from './memory-manager.js';
import { extractSkillsFromToolRecords, matchSkills, buildSkillContext } from './skill-manager.js';
import { showToast } from '../components/toast.js';
import { logInfo, logWarn, logSuccess, logError, logToolStart, logToolResult, logAgentLoop, logModelResponse } 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 } from './context-manager.js';
import type {
OllamaMessage,
OllamaStreamChunk,
ToolCall,
ToolResult,
ToolCallRecord,
TraceEntry
} from '../types.js';
const MAX_RETRIES = 2; // 工具错误自动重试次数
/** 每个工具返回给模型的最大字符数 */
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,
};
/** v4.1: 工具并行执行 — 依赖检测用 */
const TOOLS_WITH_DATA_DEPS = new Set(['web_fetch', 'edit_file', 'append_file', 'move_file', 'delete_file']);
const AGENT_SYSTEM_PROMPT = `你是一个具备工具调用能力的 AI 助手。你有 38 个工具可以调用,包括文件操作、联网搜索、网页抓取、命令执行、浏览器控制、记忆管理、MCP 工具等。
## 核心规则
1. 直接调用工具,不要写"我来帮你搜索"然后结束。有工具就调用,调用完根据结果继续下一步。
2. 多步任务必须逐步完成:搜到结果 → 给出答案。如果需要详情再抓取,但抓取失败时直接用搜索结果回答,不要卡在抓取上。
3. 需要最新信息(版本号、新闻、日期等)时,必须先用工具获取,不要凭记忆猜测。【严禁使用知识库日期】你无法确定自己的知识库中的"当前日期"是否准确,绝对不要根据训练数据推断或声明"今天是X年X月X日"。联网搜索结果中会附带真实的当前日期,以该日期为唯一可信来源。如果未搜索就不要提及任何具体日期。
4. 工具出错时分析原因并换方法重试,最多重试 2 次。同一 URL 连续失败 2 次后不要再尝试,换其他方式。
5. 不要重复调用相同参数的同一工具。
6. web_search 结果中的标题、URL、snippet 已经包含关键信息,不要忽视它们。
## 多工具协同
你可以在一轮回复中调用多个工具(并行调用),也可以在一个工具的结果基础上继续调用下一个工具(链式调用)。这是正常且鼓励的工作方式。
常见链式调用模式:
- web_search → web_fetch(搜索 → 抓取详情,但 web_fetch 失败时直接用搜索结果回答)
- list_directory → read_file(列出目录 → 读取文件)
- search_files → edit_file(搜索 → 修改)
- run_command → read_file(执行命令 → 读取输出文件)
## 重要
永远不要只输出"我将执行xxx"或"让我来搜索"然后结束。如果说了要做什么,就必须真正调用对应的工具。调用 run_command 时命令可能需要很长时间,请耐心等待。`;
const TOOL_USAGE_GUIDE = `【工具链规则(强制执行)】
1. 搜索后优先抓取:web_search 搜到结果后,选择最相关的 URL 用 web_fetch 抓取详情。
2. 抓取失败时直接用搜索结果:如果 web_fetch 失败(超时、网络错误等),不要反复重试同一个 URL。直接利用 web_search 返回的摘要和标题回答用户问题。搜索结果的 snippet 已经包含了足够的信息。
3. 不要抓取 SPA 页面:GitHub releases、npm 包页面等是 SPA(单页应用),纯 HTTP 抓取拿不到有效内容。直接从搜索结果中提取版本号等信息即可。
4. 搜索查询要进化:如果第一次搜索结果不理想,换更精确的关键词再搜,不要重复相同的查询。
5. 先搜索再回答:需要最新信息(版本号、新闻、日期等)时,先 search → 再回答。不要凭记忆猜测。
6. 文件路径上下文:用户说"修改这个文件"等模糊指代时,从最近的工具调用结果中提取实际路径。不要猜测。
7. 不要重复调用:已经成功调用过的工具+参数组合不要再调用。
8. 记忆工具:可以用 memory_search 搜索过去记忆,用 memory_add 添加重要信息。
9. 会话工具:可以用 session_list 列出历史会话,用 session_read 读取会话内容。
10. 并行调用:当多个工具调用互相独立时,可以在同一次回复中同时调用它们。
11. 管理记忆:可以用 memory_replace 更新已有记忆(子串匹配 old_text),用 memory_remove 删除不再需要的记忆。
12. 查看技能:可以用 skill_list 列出所有可用技能,用 skill_view 查看特定技能的详细工具链。
13. 【严禁猜 URL】绝对不要猜测或编造 URL 路径(如 https://example.com/docs/install)。所有 URL 必须从搜索结果中获取。如果你猜了一个 URL 并得到 404 错误,不要继续猜其他路径,应该回到搜索结果中找正确的链接。
14. 搜索结果来源:搜索结果中每条都带有标题、URL 和摘要。摘要已经包含关键信息,优先从摘要中提取答案。不要忽视来自知乎、CSDN、博客园等中文来源的结果,它们通常包含有效的技术信息。
15. 【严禁使用知识库日期】你无法确定自己的训练数据截止到哪一天,绝对不要凭记忆推断"今天是X年X月X日"。联网搜索结果的头部会标注真实的当前日期 \`[当前日期: ...]\`,这是你唯一可信赖的日期来源。回答涉及时间、版本、时效性的内容时,必须以该日期为准。如果搜索结果中的日期与你认知不符,以搜索结果为准。
16. 【上传文件已在对话中】当用户上传文件时,文件的完整内容已经作为代码块包含在用户的消息中。你不需要也不应该调用 read_file 工具去工作空间读取该文件——直接分析消息中已提供的文件内容即可。只有当用户明确要求你从工作空间读取一个新文件时,才使用 read_file 工具。`;
/** 工具名白名单:用于文本解析兜底时过滤非法工具名 */
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',
'append_file', 'edit_file', 'get_file_info', 'tree', 'download_file', 'diff_files',
'replace_in_files', 'read_multiple_files', 'git', 'compress',
'memory_search', 'memory_add', 'memory_replace', 'memory_remove', 'session_list', 'session_read',
'skill_list', 'skill_view'
]);
/**
* 文本解析兜底:当模型没有通过 tool_calls 字段返回工具调用,
* 而是在文本中写了 "Action: xxx" / "Action Input: {...}" 时,
* 从文本中提取工具调用。
*/
function parseToolCallsFromText(content: string): ToolCall[] {
const calls: ToolCall[] = [];
// 匹配:Action: tool_name(可选加粗标记)
// 然后紧跟 Action Input: {json}
// 支持格式:
// Action: write_file Action: write_file
// Action Input: {...} **Action Input:** {...}
// 也支持写在同一段落的情况
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 {
// 清理 JSON:去除可能的 markdown 代码块包裹
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 {
// JSON 解析失败,尝试修复常见问题
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;
}
/** v4.2: 从对话中自动推断用户画像 */
async function inferUserProfile(userMsg: string, assistantMsg: string): Promise<void> {
const bridge = window.metonaDesktop;
if (!bridge?.db?.getSetting) return;
const profile: Record<string, unknown> = await bridge.db.getSetting('user_profile', {}) || {};
// 技术栈检测
const techPatterns: Record<string, RegExp> = {
'Python': /\b(python|pip|py|django|flask|fastapi|pandas|numpy)\b/i,
'TypeScript': /\b(typescript|ts|tsx|deno)\b/i,
'JavaScript': /\b(javascript|js|jsx|node\.?js|npm|yarn|pnpm)\b/i,
'Go': /\b(golang|go\s+(run|build|mod)|\.go\b)/i,
'Rust': /\b(rust|cargo|rustc|\.rs\b)/i,
'Java': /\b(java|maven|gradle|spring|\.java\b)/i,
'React': /\b(react|next\.?js|jsx|tsx|vite)\b/i,
'Vue': /\b(vue|nuxt|vuex|pinia)\b/i,
'Docker': /\b(docker|dockerfile|docker-compose|container)\b/i,
'Kubernetes': /\b(k8s|kubernetes|kubectl|helm)\b/i,
'Linux': /\b(linux|ubuntu|debian|centos|bash|shell|terminal)\b/i,
'Git': /\b(git|github|gitee|gitlab|commit|push|pull|branch|merge)\b/i,
'SQL': /\b(sql|sqlite|mysql|postgres|database|查询)\b/i,
'AI/ML': /\b(machine learning|深度学习|神经网络|模型训练|ollama|llm|transformer)\b/i,
};
const combinedText = userMsg + ' ' + assistantMsg;
const currentStack: string[] = (profile.tech_stack as string[]) || [];
let changed = false;
for (const [tech, pattern] of Object.entries(techPatterns)) {
if (pattern.test(combinedText) && !currentStack.includes(tech)) {
currentStack.push(tech);
changed = true;
}
}
// 限制技术栈数量
if (currentStack.length > 15) {
currentStack.splice(0, currentStack.length - 15);
changed = true;
}
if (changed) {
profile.tech_stack = currentStack;
await bridge.db.saveSetting('user_profile', profile);
}
}
const toolResultCache = new Map<string, ToolResult>();
/** 生成工具调用缓存 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;
});
}
/**
* 格式化工具结果,生成模型友好的简洁表示
* 原始 ToolResult 对象可能包含大量冗余字段(results数组、formatted字符串等),
* 直接 JSON.stringify 会生成臃肿的 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: '未找到结果' });
// 只保留 top 5,简洁格式
const top = raw.slice(0, 5).map((r, i) =>
`[${i + 1}] ${r.title}\n URL: ${r.url}\n ${r.snippet}`
).join('\n\n');
return JSON.stringify({ success: true, query: result.query, total: result.total, results: top });
}
case 'web_fetch': {
let content = (result.content as string) || '';
// 截断过长内容,避免撑爆上下文
const webFetchMax = TOOL_MAX_RESULT_SIZE['web_fetch'] || 20000;
if (content.length > webFetchMax) content = content.slice(0, webFetchMax) + '\n... (已截断)';
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
});
}
default: {
// 通用清理:移除内部元数据字段
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;
}
}
}
export interface AgentCallbacks {
onThinking: (text: string) => void;
onContent: (text: string) => 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 }) => void;
onConfirmTool: (call: ToolCall) => Promise<boolean>;
/** Agent Loop 新迭代开始(前一轮工具执行完毕,下一轮流式输出即将开始) */
onNewIteration?: (toolCalls?: ToolCall[]) => void;
}
/** 保存执行轨迹到 SQLite */
async function saveTrace(trace: Omit<TraceEntry, 'id' | 'createdAt'>): Promise<void> {
try {
const bridge = window.metonaDesktop;
if (!bridge?.db) return;
const entry = {
id: `trace_${generateId()}`,
session_id: trace.sessionId,
step_index: trace.stepIndex,
thought: trace.thought,
action: trace.action,
action_input: trace.actionInput,
observation: trace.observation,
loop_count: trace.loopCount,
created_at: trace.createdAt
};
await bridge.db.saveTrace(entry);
} catch { /* 不阻塞主流程 */ }
}
export async function runAgentLoop(
userContent: string,
images: string[],
historyMessages: Array<{ role: string; content: string; images?: string[] }>,
callbacks: AgentCallbacks
): Promise<void> {
const api = state.get<OllamaAPI>(KEYS.API);
const model = state.get<string>('_defaultModel', '');
const currentSession = state.get(KEYS.CURRENT_SESSION);
const sessionId = currentSession?.id || 'unknown';
if (!api || !model) {
showToast('请先选择模型', 'error');
return;
}
// 新一轮对话,清空工具缓存
toolResultCache.clear();
// 检查模型是否支持 Tool Calling
const modelSupportsTools = state.get<boolean>('modelSupportsTools', false);
// 提前获取工具列表,供后续系统 prompt 构建使用
const tools = getEnabledToolDefinitions();
const useTools = tools.length > 0;
const messages: OllamaMessage[] = [];
let systemPromptParts: string[] = [];
// 注入记忆上下文
if (isMemoryEnabled() && userContent) {
const relevantMemories = searchMemories(userContent, 6);
if (relevantMemories.length > 0) {
systemPromptParts.push(buildMemoryContext(relevantMemories));
for (const m of relevantMemories) {
await markMemoryUsed(m.id);
}
}
}
// 注入工作空间上下文
const workspaceDir = getWorkspaceDirPath();
if (workspaceDir) {
systemPromptParts.push(`【工作空间】
当前工作空间目录: ${workspaceDir}
你可以使用 run_command 工具在此目录下执行命令,所有命令通过工作空间进程管理执行,无超时限制。
文件操作工具(read_file、write_file 等)的相对路径基于此目录解析。`);
}
// v4.2 注入匹配的技能上下文
if (useTools && userContent) {
const matchedSkills = await matchSkills(userContent, 3);
if (matchedSkills.length > 0) {
const skillContext = buildSkillContext(matchedSkills);
if (skillContext) {
systemPromptParts.push(skillContext);
logInfo(`技能匹配: ${matchedSkills.length} 个技能已注入`, matchedSkills.map(s => s.name).join(', '));
}
}
}
// v4.2 注入用户画像
const db = state.get<ChatDB | null>(KEYS.DB);
if (db) {
try {
const userProfile = await db.getSetting('user_profile', null);
if (userProfile && typeof userProfile === 'object') {
const parts: string[] = [];
if (userProfile.tech_stack?.length) parts.push(`技术栈: ${userProfile.tech_stack.join(', ')}`);
if (userProfile.role) parts.push(`角色: ${userProfile.role}`);
if (userProfile.style) parts.push(`代码风格: ${userProfile.style}`);
if (parts.length > 0) {
systemPromptParts.push(`【用户画像】\n${parts.join('\n')}`);
}
}
} catch { /* 不阻塞 */ }
}
// 组合 system prompt
// 实时注入当前日期(来自系统时钟,非模型知识库)
const _now = new Date();
const realDate = `${_now.getFullYear()}${_now.getMonth() + 1}${_now.getDate()}日`;
const fullSystemPrompt = [
...systemPromptParts,
`【当前日期】${realDate}(此日期来自系统时钟,绝对可信。你的训练数据可能已过时,请以此日期为准构造所有搜索查询和时效性回答。绝对不要基于训练数据推断日期。)`,
AGENT_SYSTEM_PROMPT,
TOOL_USAGE_GUIDE
].join('\n\n');
messages.push({ role: 'system', content: fullSystemPrompt });
// 添加历史消息
for (const msg of historyMessages) {
messages.push({
role: msg.role as 'user' | 'assistant',
content: msg.content,
...(msg.images?.length && { images: msg.images })
});
}
// 用户消息
const userMsg: OllamaMessage = {
role: 'user',
content: userContent || (images?.length ? (images.length > 1 ? `请分析这 ${images.length} 张图片` : '请分析这张图片') : ''),
...(images?.length && { images })
};
messages.push(userMsg);
// 上下文窗口管理:滑动窗口 + 摘要压缩 + token 裁剪
const numCtx = state.get<number>(KEYS.NUM_CTX, 24576);
const contextResult = buildContext(messages, { maxTokens: numCtx, windowSize: 20 });
messages.length = 0;
messages.push(...contextResult);
// 自动压缩:当上下文 token 超过 context window 的 50% 时,调用 LLM 摘要压缩
if (shouldAutoCompress(messages, numCtx)) {
logInfo(`自动上下文压缩触发: tokens≈${estimateTokens(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(messages, api, model, { abortController: compressAC });
if (compressed.length < messages.length || estimateTokens(compressed.map(m => m.content || '').join('')) < estimateTokens(messages.map(m => m.content || '').join(''))) {
messages.length = 0;
messages.push(...compressed);
logSuccess(`自动上下文压缩完成: 剩余 ${messages.length} 条消息`);
}
} catch (err) {
if ((err as Error).name === 'AbortError') throw err;
logWarn('自动上下文压缩失败,继续使用当前上下文', (err as Error).message);
}
}
logInfo(`ReAct Agent Loop 启动: ${model}`, `工具: ${useTools ? '开启' : '关闭'}, 记忆: ${isMemoryEnabled() ? '开启' : '关闭'}, tokens≈${estimateTokens(messages.map(m => m.content || '').join(''))}`);
// 迭代预算:从 state 读取,默认 85
const maxLoops = state.get<number>('maxTurns', 85);
let loopCount = 0;
const allToolRecords: ToolCallRecord[] = [];
const loopStartTime = Date.now();
let content = '';
/** 每轮累计 token 统计 */
let totalEvalCount = 0;
let totalPromptEvalCount = 0;
let totalInferenceNs = 0;
/** 当前轮的 Ollama 统计(流式最后一个 chunk 赋值) */
let loopEvalCount = 0;
let loopPromptEvalCount = 0;
let loopInferenceNs = 0;
/** 跨轮去重:仅跟踪成功的工具调用(失败的允许重试) */
let prevLoopSuccessKeys: string[] = [];
/** 保存上一轮的工具调用,供 onNewIteration 使用 */
let prevToolCalls: ToolCall[] = [];
const makeStats = () => ({
eval_count: totalEvalCount || undefined,
prompt_eval_count: totalPromptEvalCount || undefined,
total_duration: totalInferenceNs || undefined,
});
while (loopCount < maxLoops) {
loopCount++;
// 检查是否已中止
if (state.get<AbortController | null>(KEYS.ABORT_CONTROLLER)?.signal.aborted) {
logInfo('ReAct Agent Loop 已中止');
if (allToolRecords.length > 0) {
state.set('_abortToolRecords', allToolRecords);
}
throw new DOMException('Aborted', 'AbortError');
}
// 非首轮迭代:通知 UI 创建新的消息气泡(防止每轮内容互相覆盖)
if (loopCount > 1 && callbacks.onNewIteration) {
callbacks.onNewIteration(prevToolCalls.length > 0 ? prevToolCalls : undefined);
}
logAgentLoop(loopCount, maxLoops);
let thinking = '';
content = '';
const toolCalls: ToolCall[] = [];
// v5.1.2 预算警告:接近迭代上限时注入临时提示
let budgetWarningIdx = -1;
const remaining = maxLoops - 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.`;
messages.push({ role: 'system', content: warning });
budgetWarningIdx = messages.length - 1;
}
const abortController = new AbortController();
state.set(KEYS.ABORT_CONTROLLER, abortController);
try {
// 流式调用(无超时限制)
await api.chatStream(
{
model,
messages,
stream: true,
think: state.get<boolean>('thinkEnabled', false),
options: {
num_ctx: state.get<number>(KEYS.NUM_CTX, 24576),
temperature: state.get<number>('temperature', 0.7)
},
...(useTools && { tools })
},
(chunk: OllamaStreamChunk) => {
if (chunk.message?.thinking) {
thinking += chunk.message.thinking;
callbacks.onThinking(thinking);
}
if (chunk.message?.content) {
content += chunk.message.content;
callbacks.onContent(content);
}
if (chunk.eval_count) { loopEvalCount = chunk.eval_count; }
if (chunk.prompt_eval_count) { loopPromptEvalCount = chunk.prompt_eval_count; }
if (chunk.total_duration) { loopInferenceNs = chunk.total_duration; }
if (chunk.message?.tool_calls?.length) {
for (const tc of chunk.message.tool_calls) {
if (tc.function?.name) {
toolCalls.push({
type: 'function',
function: {
name: tc.function.name,
arguments: tc.function.arguments || {}
}
});
} else if (toolCalls.length > 0) {
const last = toolCalls[toolCalls.length - 1];
if (tc.function?.arguments && typeof tc.function.arguments === 'object') {
Object.assign(last.function.arguments, tc.function.arguments);
}
}
}
}
},
abortController
);
// 本轮流式结束,累加 token 统计
totalEvalCount += loopEvalCount;
totalPromptEvalCount += loopPromptEvalCount;
totalInferenceNs += loopInferenceNs;
state.set('_currentEvalCount', totalEvalCount);
// 重置本轮计数器(下一轮重新从 chunk 收集)
loopEvalCount = 0;
loopPromptEvalCount = 0;
loopInferenceNs = 0;
} catch (err) {
if (abortController.signal.aborted) {
logInfo('流式调用已中止');
// 保存工具记录到 state,供消费方的 catch 处理中止消息
if (allToolRecords.length > 0) {
state.set('_abortToolRecords', allToolRecords);
}
throw err; // 抛出 AbortError,由消费方统一处理
}
logError('流式调用异常', (err as Error).message);
// 清理预算警告临时消息
if (budgetWarningIdx >= 0) {
messages.splice(budgetWarningIdx, 1);
}
if (content || thinking) {
messages.push({ role: 'assistant', content: content || '(模型响应异常)', ...(thinking && { thinking }) });
}
callbacks.onDone(content || '(模型响应异常,已自动中断)', allToolRecords.length > 0 ? allToolRecords : undefined, makeStats());
return;
}
// 清理预算警告临时消息
if (budgetWarningIdx >= 0) {
messages.splice(budgetWarningIdx, 1);
}
// 提取 ReAct 思考过程
const thoughtMatch = content.match(/\*\*Thought:\*\*\s*([\s\S]*?)(?=\*\*Action:\*\*|\*\*Final Answer:\*\*|$)/i);
const thought = thoughtMatch ? thoughtMatch[1].trim() : '';
// 保存 assistant 消息
const assistantMsg: OllamaMessage = {
role: 'assistant',
content,
...(thinking && { thinking })
};
if (toolCalls.length > 0) {
assistantMsg.tool_calls = toolCalls;
}
messages.push(assistantMsg);
logModelResponse(content.length, toolCalls.length);
// 文本解析兜底:模型没通过 tool_calls 返回但文本中写了 Action
if (toolCalls.length === 0 && useTools) {
const parsedCalls = parseToolCallsFromText(content);
if (parsedCalls.length > 0) {
toolCalls.push(...parsedCalls);
}
}
// 检查是否是 Final Answer(没有工具调用且包含 Final Answer
const isFinalAnswer = content.includes('Final Answer:') || content.includes('最终答案:');
// 处理空响应:如果之前有工具调用但模型返回空内容,不立即结束
if (toolCalls.length === 0 && !content.trim() && loopCount > 1 && allToolRecords.length > 0) {
logWarn('模型返回空内容(有未处理的工具结果),继续循环');
// 移除空的 assistant 消息,避免 Ollama 解析问题
messages.pop();
// 添加一个提示性的 user 消息引导模型继续
messages.push({
role: 'user',
content: '请根据上面的工具调用结果继续回答。如果需要更多信息,可以继续调用工具。如果已有足够信息,请给出最终回答。'
});
continue;
}
if (toolCalls.length === 0) {
// 真正的最终回答(有内容或首次循环就无工具)
logInfo('无工具调用,ReAct Agent Loop 结束');
// 自动提取记忆(仅在对话结束后)
if (isMemoryEnabled() && messages.length >= 10) {
try {
const { extractMemoriesFromConversation } = await import('./memory-manager.js');
await extractMemoriesFromConversation(
messages.filter(m => m.role === 'user' || m.role === 'assistant').map(m => ({ role: m.role, content: m.content })),
currentSession?.title
);
} catch { /* 不阻塞 */ }
}
// v4.2 自动提取技能
if (allToolRecords.length >= 2) {
try {
await extractSkillsFromToolRecords(allToolRecords, userContent, currentSession?.title || '');
} catch { /* 不阻塞 */ }
}
// v4.2 自动推断用户画像
try {
await inferUserProfile(userContent, content);
} catch { /* 不阻塞 */ }
callbacks.onDone(content || '(模型未返回内容)', allToolRecords.length > 0 ? allToolRecords : undefined, makeStats());
return;
}
// 跨轮次重复检测:仅当连续两轮调用完全相同且全部成功时才终止(失败的允许重试)
const currentLoopKeys = toolCalls.map(c => getToolCacheKey(c.function.name, c.function.arguments)).sort();
const currentKeysStr = JSON.stringify(currentLoopKeys);
const prevKeysStr = JSON.stringify([...prevLoopSuccessKeys].sort());
if (currentKeysStr === prevKeysStr && currentLoopKeys.length > 0) {
// 检查是否有上一轮失败的工具——如果有,不终止,允许重试
const hasFailedInPrev = allToolRecords
.filter(r => prevLoopSuccessKeys.includes(getToolCacheKey(r.name, r.arguments)))
.some(r => r.status !== 'success');
if (!hasFailedInPrev) {
logWarn('检测到连续两轮工具调用完全相同且全部成功,终止 ReAct Loop', currentKeysStr.slice(0, 200));
callbacks.onDone(content || '(检测到重复工具调用,已自动停止)', allToolRecords, makeStats());
return;
}
logInfo('检测到重复调用但上一轮有失败,允许重试');
}
// 记录 ReAct 轨迹
const traceStep = {
sessionId,
stepIndex: loopCount,
thought: thought || content.slice(0, 200),
action: toolCalls.map(t => t.function.name).join(', '),
actionInput: JSON.stringify(toolCalls.map(t => t.function.arguments)),
observation: '',
loopCount,
createdAt: Date.now()
};
// ── v4.1 工具并行执行:将独立工具分批并行调用 ──
// 将工具调用分成批次:同一批次内的工具互相独立,可并行执行
// 跨批次的工具有依赖关系(如 web_fetch 依赖前面的 web_search
const batches: ToolCall[][] = [];
let currentBatch: ToolCall[] = [];
const batchDeps = new Map<ToolCall, string>(); // 记录每个工具依赖的前序工具名
for (const call of toolCalls) {
if (currentBatch.length === 0) {
currentBatch.push(call);
} else {
// 检查当前工具是否依赖当前批次中任何工具的结果
const needsPrevResult = currentBatch.some(prev =>
TOOLS_WITH_DATA_DEPS.has(call.function.name) && prev.function.name !== call.function.name
);
if (needsPrevResult) {
// 有依赖,当前批次结束,开启新批次
batches.push(currentBatch);
batchDeps.set(call, currentBatch[currentBatch.length - 1].function.name);
currentBatch = [call];
} else {
currentBatch.push(call);
}
}
}
if (currentBatch.length > 0) batches.push(currentBatch);
if (batches.length > 1) {
logInfo(`工具并行执行: ${toolCalls.length} 个工具 → ${batches.length} 批次(首批 ${batches[0].length} 个并行)`);
}
/** 执行单个工具(含重试),返回 [ToolCallRecord, 缓存key|null] */
const executeSingleTool = async (call: ToolCall): Promise<[ToolCallRecord, string | null]> => {
const cacheKey = getToolCacheKey(call.function.name, call.function.arguments);
// 重复调用检测
if (isDuplicateCall(call, toolCalls)) {
logWarn(`跳过重复工具调用: ${call.function.name}`);
const cached = toolResultCache.get(cacheKey);
if (cached) {
return [{
name: call.function.name, arguments: call.function.arguments,
result: cached, status: 'success' as const, timestamp: Date.now()
}, null];
}
}
// 跨轮次缓存命中
const cachedResult = toolResultCache.get(cacheKey);
if (cachedResult) {
logInfo(`工具缓存命中: ${call.function.name}`);
return [{
name: call.function.name, arguments: call.function.arguments,
result: cachedResult, status: 'success' as const, timestamp: Date.now()
}, null];
}
callbacks.onToolCallStart(call);
logToolStart(call.function.name, JSON.stringify(call.function.arguments));
// 需要确认的工具(目前只有 run_command 在 confirm 模式下)
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];
}
}
// 执行 + 自动重试
let lastError = '';
for (let retry = 0; retry <= MAX_RETRIES; retry++) {
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 (retry < MAX_RETRIES) {
logWarn(`工具重试 ${retry + 1}/${MAX_RETRIES}: ${call.function.name}`, lastError);
await new Promise(r => setTimeout(r, 500));
continue;
}
logError(`工具执行失败: ${call.function.name}`, lastError);
return [{
name: call.function.name, arguments: call.function.arguments,
result: { success: false, error: lastError },
status: 'error' as const, timestamp: Date.now()
}, null];
}
}
// unreachable
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 (abortController.signal.aborted) {
callbacks.onDone(content, allToolRecords.length > 0 ? allToolRecords : undefined, makeStats());
return;
}
const results = await Promise.all(batch.map(call => executeSingleTool(call)));
for (const [record, cacheKey] of results) {
allToolRecords.push(record);
messages.push({
role: 'tool', tool_name: record.name,
content: formatToolResultForModel(record.name, record.result!)
});
if (cacheKey) toolResultCache.set(cacheKey, 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)!);
}
}
}
// 更新跨轮去重:仅跟踪本轮成功的工具调用
prevLoopSuccessKeys = allToolRecords
.filter(r => r.status === 'success')
.map(r => getToolCacheKey(r.name, r.arguments));
// 保存本轮轨迹
traceStep.observation = allToolRecords.slice(-toolCalls.length).map(r =>
`${r.name}: ${r.result?.success ? 'success' : 'error'}`
).join('; ');
saveTrace(traceStep);
// 保存本轮工具调用,供下一轮 onNewIteration 使用
prevToolCalls = toolCalls;
}
logWarn('ReAct Agent Loop 达到最大工具调用次数限制');
callbacks.onDone(content || '(达到最大工具调用次数限制)', allToolRecords, makeStats());
}