feat: v4.1 会话增强 — /retry /undo /compress + 工具并行执行
新增功能: - /retry:重试上一轮对话,删除最后的 AI 回复并重新触发 Agent Loop - /undo:撤销最后的用户+AI 消息对 - /compress:用 LLM 摘要压缩中间对话,保留首尾上下文 - 工具并行执行:同一批次内互相独立的工具用 Promise.all 并行执行 技术细节: - input-area.ts: 命令检测在 sendMessage() 入口处,/retry 支持普通聊天和 Agent Loop 两种模式 - chat-area.ts: 新增 clearMessagesDOM() 用于 undo/compress 后强制重建 DOM - agent-engine.ts: 工具调用分批算法 — 根据 TOOLS_WITH_DATA_DEPS 集合检测依赖关系, 独立工具同批并行,有依赖的工具跨批串行
This commit is contained in:
@@ -368,6 +368,11 @@ export function appendSystemMessage(text: string, tempClass?: string): void {
|
|||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** v4.1: 清除所有消息 DOM 元素(用于 undo/compress 后强制重建) */
|
||||||
|
export function clearMessagesDOM(): void {
|
||||||
|
messagesContainerEl.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
export function clearMessages(): void {
|
export function clearMessages(): void {
|
||||||
messagesContainerEl.innerHTML = '';
|
messagesContainerEl.innerHTML = '';
|
||||||
currentPlaceholder = null;
|
currentPlaceholder = null;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { state, KEYS } from '../state/state.js';
|
|||||||
import { detectLanguage, truncate, escapeHtml, formatSize } from '../utils/utils.js';
|
import { detectLanguage, truncate, escapeHtml, formatSize } from '../utils/utils.js';
|
||||||
import { getSelectedModel, isThinkEnabled, isVisionAvailable, isToolCallingSupported } from './model-bar.js';
|
import { getSelectedModel, isThinkEnabled, isVisionAvailable, isToolCallingSupported } from './model-bar.js';
|
||||||
import {
|
import {
|
||||||
renderMessages, appendAssistantPlaceholder, appendSystemMessage,
|
renderMessages, clearMessagesDOM, appendAssistantPlaceholder, appendSystemMessage,
|
||||||
updateLastAssistantMessage, clearMessages, safeMarkdown, enableAutoScroll, updateTotalTokens
|
updateLastAssistantMessage, clearMessages, safeMarkdown, enableAutoScroll, updateTotalTokens
|
||||||
} from './chat-area.js';
|
} from './chat-area.js';
|
||||||
import { showToast } from './toast.js';
|
import { showToast } from './toast.js';
|
||||||
@@ -222,6 +222,226 @@ function renderFilePreviews(): void {
|
|||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════
|
||||||
|
// v4.1 会话管理命令:/retry, /undo, /compress
|
||||||
|
// ══════════════════════════════════════════════
|
||||||
|
|
||||||
|
async function handleRetry(): Promise<void> {
|
||||||
|
const currentSession = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
|
||||||
|
if (!currentSession || currentSession.messages.length < 2) {
|
||||||
|
showToast('没有可重试的消息', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.get<boolean>(KEYS.IS_STREAMING)) {
|
||||||
|
showToast('请先停止当前生成', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找到最后一条 user 消息
|
||||||
|
let lastUserIdx = -1;
|
||||||
|
for (let i = currentSession.messages.length - 1; i >= 0; i--) {
|
||||||
|
if (currentSession.messages[i].role === 'user') { lastUserIdx = i; break; }
|
||||||
|
}
|
||||||
|
if (lastUserIdx < 0) { showToast('没有找到用户消息', 'warning'); return; }
|
||||||
|
|
||||||
|
// 删除该 user 消息之后的所有消息(assistant 回复 + 可能的 tool 调用链)
|
||||||
|
const removedCount = currentSession.messages.length - lastUserIdx - 1;
|
||||||
|
const userMsg = currentSession.messages[lastUserIdx];
|
||||||
|
currentSession.messages = currentSession.messages.slice(0, lastUserIdx + 1);
|
||||||
|
|
||||||
|
logInfo(`重试: 删除了 ${removedCount} 条消息,重新发送`);
|
||||||
|
|
||||||
|
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||||
|
if (db) await db.saveSession(currentSession);
|
||||||
|
|
||||||
|
clearMessagesDOM();
|
||||||
|
renderMessages();
|
||||||
|
|
||||||
|
// 重新触发 Agent Loop / 普通聊天
|
||||||
|
const toolCallingEnabled = state.get<boolean>('toolCallingEnabled', false);
|
||||||
|
const model = getSelectedModel() || currentSession.model;
|
||||||
|
|
||||||
|
appendAssistantPlaceholder();
|
||||||
|
state.set(KEYS.IS_STREAMING, true);
|
||||||
|
updateSendButton(true);
|
||||||
|
|
||||||
|
if (toolCallingEnabled) {
|
||||||
|
const historyMessages = currentSession.messages
|
||||||
|
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||||||
|
.slice(0, -1) // 不包含刚保留的最后一条 user 消息
|
||||||
|
.slice(-20)
|
||||||
|
.map(m => {
|
||||||
|
let content = m.content || '';
|
||||||
|
if (m._fileContents?.length) {
|
||||||
|
const fileParts = m._fileContents.map(f => `📄 文件:\n\`\`\`${f.language}\n${f.content}\n\`\`\``);
|
||||||
|
content = content ? content + '\n\n---\n' + fileParts.join('\n\n---\n') : fileParts.join('\n\n---\n');
|
||||||
|
}
|
||||||
|
return { role: m.role, content, ...(m.images?.length && { images: m.images }) };
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runAgentLoop(userMsg.content || '', userMsg.images || [], historyMessages, {
|
||||||
|
onThinking: (thinking) => updateLastAssistantMessage('', thinking, null),
|
||||||
|
onContent: (content) => updateLastAssistantMessage(content, null, null),
|
||||||
|
onToolCallStart: () => {},
|
||||||
|
onToolCallResult: () => {},
|
||||||
|
onToolCallError: () => {},
|
||||||
|
onConfirmTool: async (call) => showToolConfirm(call),
|
||||||
|
onDone: async (finalContent, toolRecords, loopStats) => {
|
||||||
|
const assistantMsg: ChatMessage = {
|
||||||
|
role: 'assistant', content: finalContent || '', timestamp: Date.now(),
|
||||||
|
...(toolRecords?.length && { toolCalls: toolRecords }),
|
||||||
|
...(loopStats?.eval_count && { eval_count: loopStats.eval_count }),
|
||||||
|
...(loopStats?.total_duration && { total_duration: loopStats.total_duration }),
|
||||||
|
};
|
||||||
|
state.update(KEYS.CURRENT_SESSION, (s: any) => ({
|
||||||
|
...s, messages: [...s.messages, assistantMsg], updatedAt: Date.now()
|
||||||
|
}));
|
||||||
|
updateLastAssistantMessage(finalContent, null, loopStats || null);
|
||||||
|
renderMessages();
|
||||||
|
await saveCurrentSession();
|
||||||
|
updateTotalTokens();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logError('重试失败', (err as Error).message);
|
||||||
|
appendSystemMessage(`❌ 重试失败: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 普通聊天模式重试 - 直接调用 sendMessage
|
||||||
|
chatInputEl.value = userMsg.content || '';
|
||||||
|
state.set(KEYS.IS_STREAMING, false);
|
||||||
|
updateSendButton(false);
|
||||||
|
await sendMessage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.set(KEYS.IS_STREAMING, false);
|
||||||
|
updateSendButton(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUndo(): Promise<void> {
|
||||||
|
const currentSession = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
|
||||||
|
if (!currentSession || currentSession.messages.length < 2) {
|
||||||
|
showToast('没有可撤销的消息', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.get<boolean>(KEYS.IS_STREAMING)) {
|
||||||
|
showToast('请先停止当前生成', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从末尾删除:先删 assistant(含可能的 tool 消息),再删 user
|
||||||
|
const msgs = currentSession.messages;
|
||||||
|
let removeEnd = msgs.length;
|
||||||
|
let removeStart = msgs.length - 1;
|
||||||
|
|
||||||
|
// 找到最后一条 user 消息的位置
|
||||||
|
while (removeStart >= 0 && msgs[removeStart].role !== 'user') {
|
||||||
|
removeStart--;
|
||||||
|
}
|
||||||
|
if (removeStart < 0) { showToast('没有找到用户消息', 'warning'); return; }
|
||||||
|
|
||||||
|
// 删除 user 消息及之后的所有消息
|
||||||
|
const removedCount = removeEnd - removeStart;
|
||||||
|
currentSession.messages = msgs.slice(0, removeStart);
|
||||||
|
|
||||||
|
logInfo(`撤销: 删除了 ${removedCount} 条消息`);
|
||||||
|
|
||||||
|
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||||
|
if (db) await db.saveSession(currentSession);
|
||||||
|
|
||||||
|
clearMessagesDOM();
|
||||||
|
renderMessages();
|
||||||
|
updateTotalTokens();
|
||||||
|
showToast('已撤销上一轮对话', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCompress(): Promise<void> {
|
||||||
|
const currentSession = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
|
||||||
|
if (!currentSession || currentSession.messages.length < 6) {
|
||||||
|
showToast('消息太少,无需压缩', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.get<boolean>(KEYS.IS_STREAMING)) {
|
||||||
|
showToast('请先停止当前生成', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = state.get<OllamaAPI>(KEYS.API);
|
||||||
|
const model = getSelectedModel() || currentSession.model;
|
||||||
|
if (!api || !model) {
|
||||||
|
showToast('请先选择模型', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logInfo('上下文压缩: 开始');
|
||||||
|
|
||||||
|
// 取中间的消息做摘要(保留首尾各 2 条)
|
||||||
|
const messages = currentSession.messages;
|
||||||
|
const keepStart = 2;
|
||||||
|
const keepEnd = 2;
|
||||||
|
const head = messages.slice(0, keepStart);
|
||||||
|
const tail = messages.slice(-keepEnd);
|
||||||
|
const middle = messages.slice(keepStart, messages.length - keepEnd);
|
||||||
|
|
||||||
|
const conversationText = middle.map(m => {
|
||||||
|
const role = m.role === 'user' ? '用户' : 'AI';
|
||||||
|
let content = m.content || '';
|
||||||
|
// 截断过长内容
|
||||||
|
if (content.length > 500) content = content.slice(0, 500) + '...';
|
||||||
|
if (m.toolCalls?.length) content += ` [工具调用: ${m.toolCalls.map(t => t.name).join(', ')}]`;
|
||||||
|
return `${role}: ${content}`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
showToast('正在压缩上下文...', 'info');
|
||||||
|
|
||||||
|
try {
|
||||||
|
let summary = '';
|
||||||
|
await api.chatStream({
|
||||||
|
model,
|
||||||
|
messages: [{
|
||||||
|
role: 'user',
|
||||||
|
content: `请将以下对话摘要为一段简洁的上下文总结。保留关键信息:讨论的主题、得出的结论、用户的偏好、未完成的任务。用中文输出,不超过 500 字。\n\n对话记录:\n${conversationText}`
|
||||||
|
}],
|
||||||
|
stream: true,
|
||||||
|
think: false,
|
||||||
|
options: { num_ctx: 8192, temperature: 0.3 }
|
||||||
|
} as any, (chunk: OllamaStreamChunk) => {
|
||||||
|
if (chunk.message?.content) {
|
||||||
|
summary += chunk.message.content;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!summary.trim()) {
|
||||||
|
showToast('压缩失败:模型未返回内容', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建压缩后的消息列表
|
||||||
|
const summaryMsg: ChatMessage = {
|
||||||
|
role: 'system',
|
||||||
|
content: `📋 以下是对之前对话的摘要(已压缩 ${middle.length} 条消息):\n\n${summary}`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
currentSession.messages = [...head, summaryMsg, ...tail];
|
||||||
|
|
||||||
|
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||||
|
if (db) await db.saveSession(currentSession);
|
||||||
|
|
||||||
|
clearMessagesDOM();
|
||||||
|
renderMessages();
|
||||||
|
updateTotalTokens();
|
||||||
|
|
||||||
|
logInfo(`上下文压缩完成: ${middle.length} 条 → 1 条摘要`);
|
||||||
|
showToast(`已压缩 ${middle.length} 条消息为摘要`, 'success');
|
||||||
|
} catch (err) {
|
||||||
|
logError('上下文压缩失败', (err as Error).message);
|
||||||
|
showToast(`压缩失败: ${(err as Error).message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateSendButton(streaming: boolean): void {
|
function updateSendButton(streaming: boolean): void {
|
||||||
if (streaming) {
|
if (streaming) {
|
||||||
btnSendEl.innerHTML = `<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>`;
|
btnSendEl.innerHTML = `<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>`;
|
||||||
@@ -266,6 +486,24 @@ function buildApiMessages(messages: ChatMessage[]): Array<{ role: string; conten
|
|||||||
|
|
||||||
export async function sendMessage(): Promise<void> {
|
export async function sendMessage(): Promise<void> {
|
||||||
const text = chatInputEl.value.trim();
|
const text = chatInputEl.value.trim();
|
||||||
|
|
||||||
|
// ── v4.1 会话管理命令 ──
|
||||||
|
if (text === '/retry') {
|
||||||
|
chatInputEl.value = '';
|
||||||
|
await handleRetry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (text === '/undo') {
|
||||||
|
chatInputEl.value = '';
|
||||||
|
await handleUndo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (text === '/compress') {
|
||||||
|
chatInputEl.value = '';
|
||||||
|
await handleCompress();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!text && pendingImages.length === 0 && pendingFiles.length === 0) return;
|
if (!text && pendingImages.length === 0 && pendingFiles.length === 0) return;
|
||||||
if (state.get<boolean>(KEYS.IS_STREAMING)) return;
|
if (state.get<boolean>(KEYS.IS_STREAMING)) return;
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ const TOOL_EXEC_TIMEOUT = 30000; // 工具执行超时 30 秒
|
|||||||
const STREAM_TIMEOUT = 120000; // 单次流式调用超时 2 分钟
|
const STREAM_TIMEOUT = 120000; // 单次流式调用超时 2 分钟
|
||||||
const MAX_RETRIES = 2; // 工具错误自动重试次数
|
const MAX_RETRIES = 2; // 工具错误自动重试次数
|
||||||
|
|
||||||
|
/** v4.1: 工具并行执行 — 依赖检测用 */
|
||||||
|
const TOOLS_WITH_DATA_DEPS = new Set(['web_fetch', 'edit_file', 'append_file', 'move_file', 'delete_file']);
|
||||||
|
|
||||||
const AGENT_SYSTEM_PROMPT = `你是一个具备工具调用能力的 AI 助手。你有 25 个工具可以调用,包括文件操作、联网搜索、网页抓取、命令执行、记忆管理等。
|
const AGENT_SYSTEM_PROMPT = `你是一个具备工具调用能力的 AI 助手。你有 25 个工具可以调用,包括文件操作、联网搜索、网页抓取、命令执行、记忆管理等。
|
||||||
|
|
||||||
## 核心规则
|
## 核心规则
|
||||||
@@ -562,61 +565,80 @@ export async function runAgentLoop(
|
|||||||
createdAt: Date.now()
|
createdAt: Date.now()
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const call of toolCalls) {
|
// ── v4.1 工具并行执行:将独立工具分批并行调用 ──
|
||||||
// 检查是否已中止
|
// 将工具调用分成批次:同一批次内的工具互相独立,可并行执行
|
||||||
if (abortController.signal.aborted) {
|
// 跨批次的工具有依赖关系(如 web_fetch 依赖前面的 web_search)
|
||||||
callbacks.onDone(content, allToolRecords.length > 0 ? allToolRecords : undefined, makeStats());
|
const batches: ToolCall[][] = [];
|
||||||
return;
|
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)) {
|
if (isDuplicateCall(call, toolCalls)) {
|
||||||
logWarn(`跳过重复工具调用: ${call.function.name}`);
|
logWarn(`跳过重复工具调用: ${call.function.name}`);
|
||||||
const cachedKey = getToolCacheKey(call.function.name, call.function.arguments);
|
const cached = toolResultCache.get(cacheKey);
|
||||||
const cached = toolResultCache.get(cachedKey);
|
|
||||||
if (cached) {
|
if (cached) {
|
||||||
messages.push({ role: 'tool', tool_name: call.function.name, content: formatToolResultForModel(call.function.name, cached) });
|
return [{
|
||||||
callbacks.onToolCallResult(call.function.name, cached, call);
|
name: call.function.name, arguments: call.function.arguments,
|
||||||
|
result: cached, status: 'success' as const, timestamp: Date.now()
|
||||||
|
}, null];
|
||||||
}
|
}
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 跨轮次缓存命中
|
// 跨轮次缓存命中
|
||||||
const cacheKey = getToolCacheKey(call.function.name, call.function.arguments);
|
|
||||||
const cachedResult = toolResultCache.get(cacheKey);
|
const cachedResult = toolResultCache.get(cacheKey);
|
||||||
if (cachedResult) {
|
if (cachedResult) {
|
||||||
logInfo(`工具缓存命中: ${call.function.name}`);
|
logInfo(`工具缓存命中: ${call.function.name}`);
|
||||||
messages.push({ role: 'tool', tool_name: call.function.name, content: formatToolResultForModel(call.function.name, cachedResult) });
|
return [{
|
||||||
callbacks.onToolCallResult(call.function.name, cachedResult, call);
|
name: call.function.name, arguments: call.function.arguments,
|
||||||
continue;
|
result: cachedResult, status: 'success' as const, timestamp: Date.now()
|
||||||
|
}, null];
|
||||||
}
|
}
|
||||||
|
|
||||||
callbacks.onToolCallStart(call);
|
callbacks.onToolCallStart(call);
|
||||||
logToolStart(call.function.name, JSON.stringify(call.function.arguments));
|
logToolStart(call.function.name, JSON.stringify(call.function.arguments));
|
||||||
|
|
||||||
|
// 需要确认的工具(目前只有 run_command 在 confirm 模式下)
|
||||||
if (needsConfirmation(call.function.name)) {
|
if (needsConfirmation(call.function.name)) {
|
||||||
const confirmed = await callbacks.onConfirmTool(call);
|
const confirmed = await callbacks.onConfirmTool(call);
|
||||||
if (abortController.signal.aborted) {
|
|
||||||
callbacks.onDone(content, allToolRecords.length > 0 ? allToolRecords : undefined, makeStats());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
logWarn(`工具取消: ${call.function.name}`);
|
logWarn(`工具取消: ${call.function.name}`);
|
||||||
const record: ToolCallRecord = {
|
const cancelResult = { success: false, error: '用户取消了操作' };
|
||||||
name: call.function.name,
|
return [{
|
||||||
arguments: call.function.arguments,
|
name: call.function.name, arguments: call.function.arguments,
|
||||||
result: { success: false, error: '用户取消了操作' },
|
result: cancelResult, status: 'cancelled' as const, timestamp: Date.now()
|
||||||
status: 'cancelled',
|
}, null];
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 工具执行 + 自动重试
|
// 执行 + 自动重试
|
||||||
let lastError = '';
|
let lastError = '';
|
||||||
for (let retry = 0; retry <= MAX_RETRIES; retry++) {
|
for (let retry = 0; retry <= MAX_RETRIES; retry++) {
|
||||||
try {
|
try {
|
||||||
@@ -633,42 +655,55 @@ export async function runAgentLoop(
|
|||||||
)
|
)
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 成功
|
|
||||||
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: formatToolResultForModel(call.function.name, result) });
|
|
||||||
if (result.success && call.function.name !== 'run_command') {
|
|
||||||
toolResultCache.set(cacheKey, result);
|
|
||||||
}
|
|
||||||
callbacks.onToolCallResult(call.function.name, result, call);
|
|
||||||
logToolResult(call.function.name, result.success, result.success ? undefined : result.error);
|
logToolResult(call.function.name, result.success, result.success ? undefined : result.error);
|
||||||
break; // 成功,退出重试循环
|
return [{
|
||||||
|
name: call.function.name, arguments: call.function.arguments,
|
||||||
|
result, status: result.success ? 'success' as const : 'error' as const, timestamp: Date.now()
|
||||||
|
}, result.success && call.function.name !== 'run_command' ? cacheKey : null];
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
lastError = (err as Error).message;
|
lastError = (err as Error).message;
|
||||||
if (retry < MAX_RETRIES) {
|
if (retry < MAX_RETRIES) {
|
||||||
logWarn(`工具重试 ${retry + 1}/${MAX_RETRIES}: ${call.function.name}`, lastError);
|
logWarn(`工具重试 ${retry + 1}/${MAX_RETRIES}: ${call.function.name}`, lastError);
|
||||||
await new Promise(r => setTimeout(r, 500)); // 短暂延迟后重试
|
await new Promise(r => setTimeout(r, 500));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// 最终失败
|
|
||||||
logError(`工具执行失败: ${call.function.name}`, lastError);
|
logError(`工具执行失败: ${call.function.name}`, lastError);
|
||||||
const record: ToolCallRecord = {
|
return [{
|
||||||
name: call.function.name,
|
name: call.function.name, arguments: call.function.arguments,
|
||||||
arguments: call.function.arguments,
|
|
||||||
result: { success: false, error: lastError },
|
result: { success: false, error: lastError },
|
||||||
status: 'error',
|
status: 'error' as const, timestamp: Date.now()
|
||||||
timestamp: Date.now()
|
}, null];
|
||||||
};
|
}
|
||||||
allToolRecords.push(record);
|
}
|
||||||
messages.push({ role: 'tool', tool_name: call.function.name, content: JSON.stringify({ success: false, error: lastError }) });
|
// unreachable
|
||||||
callbacks.onToolCallError(call.function.name, lastError, call);
|
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)!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user