Files
metona-ollama-desktop/src/renderer/services/agent-engine.ts
T
thzxx 466df86fe4 feat: 移除系统提示词功能,由记忆系统替代
- 移除设置面板中的系统提示词开关和文本框
- 移除 input-area 和 agent-engine 中的系统提示词注入逻辑
- 移除 state/KEYS 中 SYSTEM_PROMPT 和 SYSTEM_PROMPT_ENABLED
- 移除 types.d.ts 中对应的 StateKey 类型
- 更新 README.md 设置表和帮助文档
- 记忆系统已完全覆盖系统提示词的功能,且更智能:自动提取、跨会话持久化、按相关性动态注入
2026-04-15 19:53:54 +08:00

353 lines
13 KiB
TypeScript

/**
* Agent Engine - Agent Loop 核心引擎
* 管理带工具调用的流式对话循环
*/
import { OllamaAPI } from '../api/ollama.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 { showToast } from '../components/toast.js';
import { logInfo, logWarn, logError, logToolStart, logToolResult, logThink, logStream, logAgentLoop, logModelResponse } from './log-service.js';
import { getWorkspaceDirPath } from '../components/workspace-panel.js';
import type {
OllamaMessage,
OllamaStreamChunk,
ToolCall,
ToolResult,
ToolCallRecord
} from '../types.js';
const MAX_LOOPS = 10;
const MAX_LOOP_TIME = 300000; // 5 分钟总超时
const TOOL_EXEC_TIMEOUT = 30000; // 工具执行超时 30 秒
const STREAM_TIMEOUT = 120000; // 单次流式调用超时 2 分钟
const TOOL_USAGE_GUIDE = `【工具使用规则】
1. 路径上下文:当用户说"修改这个文件"、"删除它"、"查看里面"等未指定路径时,必须从本次对话中最近的工具调用结果中提取路径。例如用户之前让你读取了 /home/user/project/config.json,后续说"修改配置"就应继续使用该路径,而不是推测新路径。
2. 工具调用结果中的 path 字段是权威的文件路径,优先使用它。
3. 如果对话中从未出现过相关路径,应先用 search_files 或 list_directory 定位,不要凭空猜测路径。
4. 对同一文件的连续操作(读取→修改、读取→删除),路径必须一致。
5. 当用户要求执行命令时,使用 run_command 工具执行。命令通过工作空间终端实时执行并显示,执行完成后将 stdout/stderr 结果告知用户。`;
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; total_duration?: number }) => void;
onConfirmTool: (call: ToolCall) => Promise<boolean>;
}
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', '');
if (!api || !model) {
showToast('请先选择模型', 'error');
return;
}
// 检查模型是否支持 Tool Calling
const modelSupportsTools = state.get<boolean>('modelSupportsTools', false);
if (!modelSupportsTools) {
logWarn(`模型 ${model} 不支持 Tool Calling`, 'Agent Loop 可能不稳定');
}
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 等)的相对路径基于此目录解析。`);
}
if (systemPromptParts.length > 0) {
messages.push({ role: 'system', content: systemPromptParts.join('\n\n') + '\n\n' + TOOL_USAGE_GUIDE });
} else {
messages.push({ role: 'system', content: TOOL_USAGE_GUIDE });
}
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 };
if (images?.length) userMsg.images = images;
messages.push(userMsg);
const tools = getEnabledToolDefinitions();
const useTools = tools.length > 0;
logInfo(`Agent Loop 启动: ${model}`, `工具: ${useTools ? '开启' : '关闭'}, 记忆: ${isMemoryEnabled() ? '开启' : '关闭'}`);
let loopCount = 0;
const allToolRecords: ToolCallRecord[] = [];
const loopStartTime = Date.now();
let content = '';
let totalEvalCount = 0;
const makeStats = () => {
const totalDuration = (Date.now() - loopStartTime) * 1e6; // 转为纳秒
return { eval_count: totalEvalCount || undefined, total_duration: totalDuration };
};
while (loopCount < MAX_LOOPS) {
loopCount++;
// 全局超时检查
if (Date.now() - loopStartTime > MAX_LOOP_TIME) {
logWarn('Agent Loop 达到最大运行时间(5分钟),自动停止');
callbacks.onDone(content || '(达到最大运行时间限制)', allToolRecords, makeStats());
return;
}
// 检查是否已中止
if (state.get<AbortController | null>(KEYS.ABORT_CONTROLLER)?.signal.aborted) {
logInfo('Agent Loop 已中止');
callbacks.onDone(content, allToolRecords.length > 0 ? allToolRecords : undefined, makeStats());
return;
}
logAgentLoop(loopCount, MAX_LOOPS);
let thinking = '';
content = '';
const toolCalls: ToolCall[] = [];
const abortController = new AbortController();
state.set(KEYS.ABORT_CONTROLLER, abortController);
try {
// 带超时保护的流式调用
await Promise.race([
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);
// 每收到 200 字符记录一次思考日志
if (thinking.length % 200 < 10) {
logThink(thinking);
}
}
if (chunk.message?.content) {
content += chunk.message.content;
callbacks.onContent(content);
}
if (chunk.eval_count) totalEvalCount = chunk.eval_count;
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) {
// 增量 arguments 追加到当前最后一个 tool_call
const last = toolCalls[toolCalls.length - 1];
if (tc.function?.arguments && typeof tc.function.arguments === 'object') {
Object.assign(last.function.arguments, tc.function.arguments);
}
}
}
}
},
abortController
),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('模型响应超时(2分钟无数据)')), STREAM_TIMEOUT)
)
]);
} catch (err) {
if (abortController.signal.aborted) {
logInfo('流式调用已中止');
if (content || thinking) {
messages.push({
role: 'assistant',
content,
...(thinking && { thinking })
});
}
callbacks.onDone(content, allToolRecords.length > 0 ? allToolRecords : undefined, makeStats());
return;
}
// 超时错误 — 保留已生成的内容
if ((err as Error).message.includes('超时')) {
logError('流式调用超时(2分钟无数据)', (err as Error).message);
if (content || thinking) {
messages.push({
role: 'assistant',
content: content || '(模型响应超时)',
...(thinking && { thinking })
});
}
callbacks.onDone(content || '(模型响应超时,已自动中断)', allToolRecords.length > 0 ? allToolRecords : undefined, makeStats());
return;
}
throw err;
}
const assistantMsg: OllamaMessage = {
role: 'assistant',
content,
...(thinking && { thinking })
};
if (toolCalls.length > 0) {
assistantMsg.tool_calls = toolCalls;
// 官方示例要求:tool_calls 时同时保留 content 和 thinking
// 确保 content 不为空(模型可能在调用工具前说了话)
}
messages.push(assistantMsg);
logModelResponse(content.length, toolCalls.length);
if (toolCalls.length === 0) {
logInfo('无工具调用,Agent Loop 结束');
callbacks.onDone(content, allToolRecords.length > 0 ? allToolRecords : undefined, makeStats());
return;
}
for (const call of toolCalls) {
// 检查是否已中止
if (abortController.signal.aborted) {
callbacks.onDone(content, allToolRecords.length > 0 ? allToolRecords : undefined, makeStats());
return;
}
callbacks.onToolCallStart(call);
logToolStart(call.function.name, JSON.stringify(call.function.arguments));
if (needsConfirmation(call.function.name)) {
const confirmed = await callbacks.onConfirmTool(call);
// 确认后再次检查是否已中止
if (abortController.signal.aborted) {
callbacks.onDone(content, allToolRecords.length > 0 ? allToolRecords : undefined, makeStats());
return;
}
if (!confirmed) {
logWarn(`工具取消: ${call.function.name}`, '用户取消了操作');
const record: ToolCallRecord = {
name: call.function.name,
arguments: call.function.arguments,
result: { success: false, error: '用户取消了操作' },
status: 'cancelled',
timestamp: Date.now()
};
allToolRecords.push(record);
messages.push({
role: 'tool',
tool_name: call.function.name,
content: JSON.stringify({ success: false, error: '用户取消了操作' })
});
callbacks.onToolCallError(call.function.name, '用户取消', call);
continue;
}
}
try {
// 工具执行(run_command 无超时,其他工具 30 秒超时)
logInfo(`执行工具: ${call.function.name}`, JSON.stringify(call.function.arguments));
let result: ToolResult;
if (call.function.name === 'run_command') {
// run_command 通过 workspace IPC 执行,无超时限制
result = await executeTool(call.function.name, call.function.arguments);
} else {
// 其他工具保持 30 秒超时保护
result = await Promise.race([
executeTool(call.function.name, call.function.arguments).catch(err =>
({ success: false, error: err?.message || String(err) }) as ToolResult
),
new Promise<ToolResult>((_, reject) =>
setTimeout(() => reject(new Error('工具执行超时(30秒)')), TOOL_EXEC_TIMEOUT)
)
]);
}
const record: ToolCallRecord = {
name: call.function.name,
arguments: call.function.arguments,
result,
status: result.success ? 'success' : 'error',
timestamp: Date.now()
};
allToolRecords.push(record);
messages.push({
role: 'tool',
tool_name: call.function.name,
content: JSON.stringify(result)
});
callbacks.onToolCallResult(call.function.name, result, call);
logToolResult(call.function.name, result.success, result.success ? undefined : result.error);
} catch (err) {
const errorMsg = (err as Error).message;
logError(`工具执行失败: ${call.function.name}`, errorMsg);
const record: ToolCallRecord = {
name: call.function.name,
arguments: call.function.arguments,
result: { success: false, error: errorMsg },
status: 'error',
timestamp: Date.now()
};
allToolRecords.push(record);
messages.push({
role: 'tool',
tool_name: call.function.name,
content: JSON.stringify({ success: false, error: errorMsg })
});
callbacks.onToolCallError(call.function.name, errorMsg, call);
}
}
}
logWarn('Agent Loop 达到最大工具调用次数限制');
callbacks.onDone(content || '(达到最大工具调用次数限制)', allToolRecords, makeStats());
}