diff --git a/packages/message/types.ts b/packages/message/types.ts index fea7719e6..1b323b8de 100644 --- a/packages/message/types.ts +++ b/packages/message/types.ts @@ -11,6 +11,8 @@ export type TMessagQueueUnit = { export type TMessageCommAction = { action: string; data?: NonNullable; + /** 可选请求关联 ID,用于连接上的请求/响应批次匹配。 */ + requestId?: string; msgQueue?: never; code?: never; }; diff --git a/src/app/repo/agent_chat.test.ts b/src/app/repo/agent_chat.test.ts index f3b1e2c6e..12cb8f8ee 100644 --- a/src/app/repo/agent_chat.test.ts +++ b/src/app/repo/agent_chat.test.ts @@ -10,6 +10,8 @@ function createMockOPFS() { data = content; }), close: vi.fn(async () => {}), + // 真实 OPFS 的 abort() 放弃这次写入的临时副本,不影响 dir 里已提交的旧内容 + abort: vi.fn(async () => {}), getData: () => data, }; } @@ -250,3 +252,34 @@ describe("AgentChatRepo 附件存储", () => { expect(await repo.getAttachment("att-del-2")).toBeNull(); }); }); + +describe("AgentChatRepo.saveMessages 取消安全(finding 4)", () => { + let repo: AgentChatRepo; + + beforeEach(() => { + createMockOPFS(); + repo = new AgentChatRepo(); + }); + + it("signal 已 abort 时 saveMessages 应 reject 且不覆盖已持久化的旧消息", async () => { + const convId = "conv-cancel"; + const oldMessage = { + id: "msg-old", + conversationId: convId, + role: "user" as const, + content: "原始历史", + createtime: Date.now(), + }; + await repo.saveMessages(convId, [oldMessage]); + + const controller = new AbortController(); + controller.abort(); + await expect( + repo.saveMessages(convId, [{ ...oldMessage, id: "msg-new", content: "摘要覆盖" }], controller.signal) + ).rejects.toThrow("Aborted"); + + // 旧内容必须完整保留,没有被这次放弃的写入部分覆盖或破坏 + const stored = await repo.getMessages(convId); + expect(stored).toEqual([oldMessage]); + }); +}); diff --git a/src/app/repo/agent_chat.ts b/src/app/repo/agent_chat.ts index b91d1da8d..02a4464a7 100644 --- a/src/app/repo/agent_chat.ts +++ b/src/app/repo/agent_chat.ts @@ -99,10 +99,12 @@ export class AgentChatRepo extends OPFSRepo { } } - // 保存整个消息列表(用于批量更新) - async saveMessages(conversationId: string, messages: ChatMessage[]): Promise { + // 保存整个消息列表(用于批量更新)。 + // signal 可选:传入时若在写入落定前已 abort,则放弃这次整份覆写而不是让它继续提交 + // (OPFS createWritable() 本身是事务性的,写入的是临时副本,abort 不会影响已持久化的旧内容,见 finding 4) + async saveMessages(conversationId: string, messages: ChatMessage[], signal?: AbortSignal): Promise { const messagesDir = await this.getChildDir(MESSAGES_DIR); - await this.writeJsonFile(`${conversationId}.json`, messages, messagesDir); + await this.writeJsonFile(`${conversationId}.json`, messages, messagesDir, signal); } // ---- 附件存储 ---- diff --git a/src/app/repo/agent_model.ts b/src/app/repo/agent_model.ts index aa6525842..1413e3866 100644 --- a/src/app/repo/agent_model.ts +++ b/src/app/repo/agent_model.ts @@ -1,4 +1,5 @@ import type { AgentModelConfig } from "@App/app/service/agent/core/types"; +import { normalizeModelLimits } from "@App/app/service/agent/core/model_context"; import { Repo, loadCache } from "./repo"; const DEFAULT_MODEL_KEY = "agent_model:__default__"; @@ -21,9 +22,9 @@ export class AgentModelRepo extends Repo { return this.get(id); } - // 保存模型 + // 保存模型:存储边界统一归一化 maxTokens/contextWindow(见 model_context.ts,finding 10) async saveModel(model: AgentModelConfig): Promise { - await this._save(model.id, model); + await this._save(model.id, normalizeModelLimits(model)); } // 删除模型 diff --git a/src/app/repo/opfs_repo.ts b/src/app/repo/opfs_repo.ts index 626059777..0104be3fd 100644 --- a/src/app/repo/opfs_repo.ts +++ b/src/app/repo/opfs_repo.ts @@ -56,12 +56,31 @@ export class OPFSRepo { } } - // 写入 JSON 文件 - protected async writeJsonFile(filename: string, data: unknown, dir?: FileSystemDirectoryHandle): Promise { + // 写入 JSON 文件。 + // OPFS 的 createWritable() 本身是事务性的:write() 写入的是临时副本,只有 close() 成功 + // 才会原子替换原文件;调用方持有的旧内容在此之前始终完整可读。 + // 传入 signal 时,若在"调用 close() 之前"已 abort,则改为 writable.abort() 放弃这次写入。 + // 诚实说明这里的边界:这只保证 close() 调用前的 abort 一定不会提交;一旦 close() 已经 + // 发出,FSA 规范不提供可靠的方式中途取消它,abort 恰好落在 close() 进行期间这个极窄窗口 + // 理论上仍可能提交。调用方(compact_service.ts / chat_service_base.ts)都会在 + // saveMessages() resolve 之后再次检查 signal,因此即使命中这个窗口,也不会对外报告 + // 虚假的成功事件(compact_done/done)——唯一的残留风险是磁盘内容被替换但会话已判定为 + // 取消,这是一个已知的、极窄的边界情况,未做完整的事务回滚(见 finding 2)。 + protected async writeJsonFile( + filename: string, + data: unknown, + dir?: FileSystemDirectoryHandle, + signal?: AbortSignal + ): Promise { + if (signal?.aborted) throw new Error("Aborted"); const targetDir = dir || (await this.getDir()); const fileHandle = await targetDir.getFileHandle(filename, { create: true }); const writable = await fileHandle.createWritable(); await writable.write(JSON.stringify(data)); + if (signal?.aborted) { + await writable.abort().catch(() => {}); + throw new Error("Aborted"); + } await writable.close(); } diff --git a/src/app/service/agent/core/abort_utils.ts b/src/app/service/agent/core/abort_utils.ts new file mode 100644 index 000000000..819b6670e --- /dev/null +++ b/src/app/service/agent/core/abort_utils.ts @@ -0,0 +1,32 @@ +/** Create a standard abort error for tool execution flows. */ +export function createAbortError(message = "Aborted"): Error { + return new Error(message); +} + +/** Throw immediately when the signal is already aborted. */ +export function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw createAbortError(); + } +} + +/** Reject as soon as the signal aborts, while leaving the original promise untouched. */ +export function raceWithAbort(promise: Promise, signal?: AbortSignal): Promise { + if (!signal) return promise; + if (signal.aborted) return Promise.reject(createAbortError()); + + return new Promise((resolve, reject) => { + const onAbort = () => reject(createAbortError()); + signal.addEventListener("abort", onAbort, { once: true }); + promise.then( + (value) => { + signal.removeEventListener("abort", onAbort); + resolve(value); + }, + (error) => { + signal.removeEventListener("abort", onAbort); + reject(error); + } + ); + }); +} diff --git a/src/app/service/agent/core/agent.test.ts b/src/app/service/agent/core/agent.test.ts index 369d2543f..4d8e1e87f 100644 --- a/src/app/service/agent/core/agent.test.ts +++ b/src/app/service/agent/core/agent.test.ts @@ -268,7 +268,7 @@ describe("OpenAI Provider", () => { it("应正确解析带 usage 的 done 事件", async () => { const events = await collectEvents(parseOpenAIStream, [ - 'data: {"choices":[{"delta":{"content":"hi"}}]}\n\n', + 'data: {"choices":[{"delta":{"content":"hi"},"finish_reason":"stop"}]}\n\n', 'data: {"usage":{"prompt_tokens":10,"completion_tokens":5}}\n\n', ]); @@ -328,18 +328,17 @@ describe("OpenAI Provider", () => { } }); - it("signal 中断时不应产生错误事件", async () => { + it("signal 中断时应 reject 而不是静默完成(否则外层 callLLM 永远挂起)", async () => { const abortController = new AbortController(); abortController.abort(); - const events = await collectEvents( - parseOpenAIStream, - ['data: {"choices":[{"delta":{"content":"hello"}}]}\n\n'], - abortController.signal - ); - - // signal 已中断,不应处理任何数据 - expect(events).toHaveLength(0); + await expect( + collectEvents( + parseOpenAIStream, + ['data: {"choices":[{"delta":{"content":"hello"}}]}\n\n'], + abortController.signal + ) + ).rejects.toThrow("Aborted"); }); }); }); @@ -548,7 +547,7 @@ function buildSSEResponse(sseChunks: string[]): Response { // 辅助:构造纯文本 SSE 数据(OpenAI 格式) function makeTextSSE(text: string, usage?: { prompt_tokens: number; completion_tokens: number }): string[] { const chunks: string[] = []; - chunks.push(`data: {"choices":[{"delta":{"content":"${text}"}}]}\n\n`); + chunks.push(`data: {"choices":[{"delta":{"content":"${text}"},"finish_reason":"stop"}]}\n\n`); if (usage) { chunks.push(`data: {"usage":${JSON.stringify(usage)}}\n\n`); } else { @@ -569,7 +568,7 @@ function makeToolCallSSE( `data: {"choices":[{"delta":{"tool_calls":[{"id":"${toolId}","function":{"name":"${toolName}","arguments":""}}]}}]}\n\n` ); chunks.push( - `data: {"choices":[{"delta":{"tool_calls":[{"function":{"arguments":"${args.replace(/"/g, '\\"')}"}}]}}]}\n\n` + `data: {"choices":[{"delta":{"tool_calls":[{"function":{"arguments":"${args.replace(/"/g, '\\"')}"}}]}, "finish_reason":"tool_calls"}]}\n\n` ); if (usage) { chunks.push(`data: {"usage":${JSON.stringify(usage)}}\n\n`); @@ -588,6 +587,7 @@ function createTestService() { listConversations: vi.fn().mockResolvedValue([]), saveConversation: vi.fn().mockResolvedValue(undefined), saveMessages: vi.fn().mockResolvedValue(undefined), + updateMessage: vi.fn().mockResolvedValue(undefined), getAttachment: vi.fn().mockResolvedValue(null), saveAttachment: vi.fn().mockResolvedValue(0), }); @@ -699,7 +699,7 @@ describe("callLLMWithToolLoop", () => { expect(fetchSpy).toHaveBeenCalledTimes(2); // 工具应被执行 - expect(mockExecutor.execute).toHaveBeenCalledWith({ city: "北京" }); + expect(mockExecutor.execute).toHaveBeenCalledWith({ city: "北京" }, expect.any(AbortSignal)); // messages 应包含 assistant(tool_call) + tool(result) + user 原始消息 expect(messages.length).toBe(3); // user + assistant(toolCalls) + tool(result) @@ -845,7 +845,10 @@ describe("callLLMWithToolLoop", () => { // scriptCallback 应被调用 expect(scriptCallback).toHaveBeenCalledTimes(1); - expect(scriptCallback).toHaveBeenCalledWith([expect.objectContaining({ id: "call_1", name: "script_tool" })]); + expect(scriptCallback).toHaveBeenCalledWith( + [expect.objectContaining({ id: "call_1", name: "script_tool" })], + expect.any(AbortSignal) + ); expect(events.find((e) => e.type === "done")).toBeDefined(); }); diff --git a/src/app/service/agent/core/agent_config.test.ts b/src/app/service/agent/core/agent_config.test.ts new file mode 100644 index 000000000..1fa76cd68 --- /dev/null +++ b/src/app/service/agent/core/agent_config.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { AgentConfigRepo, DEFAULT_CHAT_MAX_ITERATIONS } from "./agent_config"; + +describe("AgentConfigRepo", () => { + let repo: AgentConfigRepo; + + beforeEach(() => { + chrome.storage.local.clear(); + repo = new AgentConfigRepo(); + }); + + describe("getConfig", () => { + it("未保存过配置时应返回默认的 chatMaxIterations", async () => { + const config = await repo.getConfig(); + expect(config.chatMaxIterations).toBe(DEFAULT_CHAT_MAX_ITERATIONS); + }); + + it("storage 读取异常时应返回默认配置而非抛出异常", async () => { + const originalGet = chrome.storage.local.get; + chrome.storage.local.get = () => Promise.reject(new Error("storage error")); + try { + const config = await repo.getConfig(); + expect(config.chatMaxIterations).toBe(DEFAULT_CHAT_MAX_ITERATIONS); + } finally { + chrome.storage.local.get = originalGet; + } + }); + }); + + describe("saveConfig", () => { + it("应持久化自定义的 chatMaxIterations 并可被重新读取", async () => { + await repo.saveConfig({ chatMaxIterations: 200 }); + const config = await repo.getConfig(); + expect(config.chatMaxIterations).toBe(200); + }); + + it("保存部分字段时应与默认配置合并而非丢失未指定字段", async () => { + await repo.saveConfig({ chatMaxIterations: 120 }); + const config = await repo.getConfig(); + expect(config).toEqual({ chatMaxIterations: 120 }); + }); + + it("保存值超过上限 1000 时应截断为 1000", async () => { + await repo.saveConfig({ chatMaxIterations: 999999 }); + const config = await repo.getConfig(); + expect(config.chatMaxIterations).toBe(1000); + }); + + it("保存 0 或负数时应截断为下限 1", async () => { + await repo.saveConfig({ chatMaxIterations: 0 }); + expect((await repo.getConfig()).chatMaxIterations).toBe(1); + + await repo.saveConfig({ chatMaxIterations: -20 }); + expect((await repo.getConfig()).chatMaxIterations).toBe(1); + }); + + it("保存非整数时应四舍五入", async () => { + await repo.saveConfig({ chatMaxIterations: 50.6 }); + const config = await repo.getConfig(); + expect(config.chatMaxIterations).toBe(51); + }); + }); + + describe("边界防护 —— storage 中存在非法值(如损坏数据、devtools 直接写入、旧版本遗留值)", () => { + it("getConfig 读取到超出范围或非法的原始值时应归一化而非直接透传", async () => { + const cases: Array<[unknown, number]> = [ + [0, 1], + [-5, 1], + [999999, 1000], + [NaN, DEFAULT_CHAT_MAX_ITERATIONS], + ["not-a-number", DEFAULT_CHAT_MAX_ITERATIONS], + [null, DEFAULT_CHAT_MAX_ITERATIONS], + [12.9, 13], + ]; + + for (const [stored, expected] of cases) { + await chrome.storage.local.set({ agent_general_config: { chatMaxIterations: stored } }); + const config = await repo.getConfig(); + expect(config.chatMaxIterations).toBe(expected); + } + }); + }); +}); diff --git a/src/app/service/agent/core/agent_config.ts b/src/app/service/agent/core/agent_config.ts new file mode 100644 index 000000000..954a63087 --- /dev/null +++ b/src/app/service/agent/core/agent_config.ts @@ -0,0 +1,40 @@ +// Agent 通用设置(对话行为相关,非 Skill/Model/Search 等专项配置) + +export type AgentGeneralConfig = { + chatMaxIterations: number; // UI 对话的 tool calling 最大循环次数 +}; + +const STORAGE_KEY = "agent_general_config"; + +export const DEFAULT_CHAT_MAX_ITERATIONS = 50; +export const MIN_CHAT_MAX_ITERATIONS = 1; +export const MAX_CHAT_MAX_ITERATIONS = 1000; + +const DEFAULT_CONFIG: AgentGeneralConfig = { + chatMaxIterations: DEFAULT_CHAT_MAX_ITERATIONS, +}; + +// 归一化 chatMaxIterations:非法/非有限值回退默认值,其余四舍五入并截断到 [MIN, MAX]。 +// 由 repo 统一把关,防止损坏的 storage、旧版本遗留值、或绕过 Settings UI 的调用方写入越界值。 +// 同时导出供 ChatService 校验直接传入的 maxIterations(例如显式传入负数等非法值)。 +export function normalizeChatMaxIterations(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_CHAT_MAX_ITERATIONS; + return Math.min(MAX_CHAT_MAX_ITERATIONS, Math.max(MIN_CHAT_MAX_ITERATIONS, Math.round(value))); +} + +export class AgentConfigRepo { + async getConfig(): Promise { + try { + const result = await chrome.storage.local.get(STORAGE_KEY); + const stored = result[STORAGE_KEY] as Partial | undefined; + return { chatMaxIterations: normalizeChatMaxIterations(stored?.chatMaxIterations) }; + } catch { + return DEFAULT_CONFIG; + } + } + + async saveConfig(config: AgentGeneralConfig): Promise { + const normalized: AgentGeneralConfig = { chatMaxIterations: normalizeChatMaxIterations(config.chatMaxIterations) }; + await chrome.storage.local.set({ [STORAGE_KEY]: normalized }); + } +} diff --git a/src/app/service/agent/core/context_elision.test.ts b/src/app/service/agent/core/context_elision.test.ts new file mode 100644 index 000000000..e603e48f3 --- /dev/null +++ b/src/app/service/agent/core/context_elision.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, it } from "vitest"; +import { + elideOldToolResults, + elideOldAttachments, + ELIDED_TOOL_RESULT_STUB, + elideUntilWithinBudget, + estimateRequestTokens, +} from "./context_elision"; +import type { AgentModelConfig, ChatRequest, ToolCall } from "./types"; + +const VISION_MODEL: AgentModelConfig = { + id: "m-vision", + name: "Vision", + provider: "openai", + apiBaseUrl: "", + apiKey: "", + model: "gpt-4o", +}; + +const NON_VISION_MODEL: AgentModelConfig = { + id: "m-text", + name: "Text", + provider: "openai", + apiBaseUrl: "", + apiKey: "", + model: "gpt-4", +}; + +// 构造一轮 assistant(带 toolCalls) + N 条 tool 结果 +function round(index: number, toolCount = 1): ChatRequest["messages"] { + const assistantMsg: ChatRequest["messages"][number] = { + role: "assistant", + content: `assistant-${index}`, + toolCalls: Array.from({ length: toolCount }, (_, i) => ({ + id: `t${index}-${i}`, + name: "execute_script", + arguments: "{}", + })), + }; + const toolMsgs: ChatRequest["messages"] = Array.from({ length: toolCount }, (_, i) => ({ + role: "tool" as const, + content: `tool-result-${index}-${i}`, + toolCallId: `t${index}-${i}`, + })); + return [assistantMsg, ...toolMsgs]; +} + +describe("elideOldToolResults", () => { + it("轮次数不超过保留窗口时不应裁剪任何 tool 结果", () => { + const messages: ChatRequest["messages"] = [{ role: "user", content: "开始" }, ...round(1), ...round(2)]; + elideOldToolResults(messages, 5); + expect(messages.every((m) => m.role !== "tool" || m.content !== ELIDED_TOOL_RESULT_STUB)).toBe(true); + }); + + it("超过保留窗口的旧 tool 结果应被替换为占位文本,最近 K 轮保持不变", () => { + const messages: ChatRequest["messages"] = [ + { role: "user", content: "开始" }, + ...round(1), + ...round(2), + ...round(3), + ]; + elideOldToolResults(messages, 2); + + // 第 1 轮(最旧,超出保留窗口)应被裁剪 + const round1Tool = messages.find((m) => m.toolCallId === "t1-0"); + expect(round1Tool?.content).toBe(ELIDED_TOOL_RESULT_STUB); + + // 第 2、3 轮(最近 2 轮)应保持原文 + const round2Tool = messages.find((m) => m.toolCallId === "t2-0"); + const round3Tool = messages.find((m) => m.toolCallId === "t3-0"); + expect(round2Tool?.content).toBe("tool-result-2-0"); + expect(round3Tool?.content).toBe("tool-result-3-0"); + }); + + it("assistant 消息文本与 toolCalls 不应被裁剪,只处理 tool 角色消息", () => { + const messages: ChatRequest["messages"] = [ + { role: "user", content: "开始" }, + ...round(1), + ...round(2), + ...round(3), + ]; + elideOldToolResults(messages, 2); + + const round1Assistant = messages.find((m) => m.role === "assistant" && m.content === "assistant-1"); + expect(round1Assistant).toBeDefined(); + expect(round1Assistant?.toolCalls).toHaveLength(1); + }); + + it("已裁剪过的 tool 结果再次裁剪应保持幂等", () => { + const messages: ChatRequest["messages"] = [ + { role: "user", content: "开始" }, + ...round(1), + ...round(2), + ...round(3), + ]; + elideOldToolResults(messages, 2); + elideOldToolResults(messages, 2); + + const round1Tool = messages.find((m) => m.toolCallId === "t1-0"); + expect(round1Tool?.content).toBe(ELIDED_TOOL_RESULT_STUB); + }); + + it("单轮内多个 tool 结果应一并裁剪", () => { + const messages: ChatRequest["messages"] = [ + { role: "user", content: "开始" }, + ...round(1, 3), + ...round(2), + ...round(3), + ]; + elideOldToolResults(messages, 2); + + expect(messages.find((m) => m.toolCallId === "t1-0")?.content).toBe(ELIDED_TOOL_RESULT_STUB); + expect(messages.find((m) => m.toolCallId === "t1-1")?.content).toBe(ELIDED_TOOL_RESULT_STUB); + expect(messages.find((m) => m.toolCallId === "t1-2")?.content).toBe(ELIDED_TOOL_RESULT_STUB); + }); +}); + +describe("上下文预算估算与裁剪", () => { + it("按 UTF-8 字节和工具定义保守估算请求 token", () => { + const messages: ChatRequest["messages"] = [{ role: "user", content: "你好世界" }]; + const small = estimateRequestTokens(messages, []); + const large = estimateRequestTokens(messages, [{ name: "工具", description: "x".repeat(1000) }]); + + expect(large).toBeGreaterThan(small); + }); + + it("完整历史超过预算时裁剪工具结果,预算内的小历史保持原文", () => { + const smallHistory: ChatRequest["messages"] = [{ role: "user", content: "你好" }, ...round(1), ...round(2)]; + elideUntilWithinBudget(smallHistory, 1000, [], 0.6); + expect(smallHistory.find((message) => message.role === "tool")?.content).toBe("tool-result-1-0"); + + const largeHistory: ChatRequest["messages"] = []; + for (let i = 0; i < 5; i++) { + largeHistory.push(...round(i)); + const tool = largeHistory[largeHistory.length - 1]; + if (tool.role === "tool") tool.content = "结果".repeat(5000); + } + elideUntilWithinBudget(largeHistory, 1000, [], 0.6); + expect( + largeHistory + .filter((message) => message.role === "tool") + .every((message) => message.content === ELIDED_TOOL_RESULT_STUB) + ).toBe(true); + }); + + it("vision 模型下按图片附件实际字节估算,并可只省略较旧的多模态块", () => { + const messages: ChatRequest["messages"] = [ + { role: "user", content: [{ type: "image", attachmentId: "old", mimeType: "image/png" }] }, + { role: "assistant", content: "已看到图片" }, + { role: "user", content: "继续" }, + ]; + // 用较大的真实照片量级(600KB)而不是 6000 字节:折算后(/40)约 2 万 token, + // 明显超出 1000 的预算才能触发裁剪;6000 字节这种量级折算后不到 200 token,不足以撑爆预算。 + const sizes = new Map([["old", 600_000]]); + // 图片按 IMAGE_CONSERVATIVE_BYTES_PER_TOKEN(40)折算为 token(见 finding 8:不能再把 + // base64 字节数 1:1 当 token 数,否则普通照片会被判定为超出上下文)。 + // 验证 base64 展开确实被计入——若只是把原始字节数朴素除以换算系数(600000/40=15000), + // 结果不会低于它。 + expect(estimateRequestTokens(messages, [], sizes, VISION_MODEL)).toBeGreaterThan(15_000); + expect(elideUntilWithinBudget(messages, 1000, [], 0.9, sizes, VISION_MODEL)).toBe(true); + expect(messages[0].content).toEqual([{ type: "text", text: expect.stringContaining("attachment elided") }]); + expect((messages[0].content as any)[0].text).toContain("uploads/old"); + expect(messages[2].content).toBe("继续"); + }); + + it("vision 模型下缺失图片大小时应使用文本降级估算而不是 Infinity", () => { + const messages: ChatRequest["messages"] = [ + { role: "user", content: [{ type: "image", attachmentId: "missing", mimeType: "image/png" }] }, + ]; + + const estimate = estimateRequestTokens(messages, [], undefined, VISION_MODEL); + + expect(Number.isFinite(estimate)).toBe(true); + expect(estimate).toBeGreaterThan(0); + }); + + it("非 vision 模型下不解析图片,不应因缺失大小把预算估算撑爆", () => { + const messages: ChatRequest["messages"] = [ + { role: "user", content: [{ type: "image", attachmentId: "old", mimeType: "image/png" }] }, + ]; + // 没有提供 size(模拟无法读取该附件),非 vision 模型下图片从不内联,不应导致 Infinity + expect(estimateRequestTokens(messages, [], undefined, NON_VISION_MODEL)).toBeLessThan(Number.POSITIVE_INFINITY); + }); + + it("file 块从不内联为二进制,缺失大小也不应导致估算为 Infinity", () => { + const messages: ChatRequest["messages"] = [ + { + role: "user", + content: [{ type: "file", attachmentId: "missing-file", mimeType: "application/pdf", name: "a.pdf" }], + }, + ]; + expect(estimateRequestTokens(messages, [], undefined, VISION_MODEL)).toBeLessThan(Number.POSITIVE_INFINITY); + }); + + it("audio 块从不内联为二进制,即使是 vision 模型也不应计入大文件字节", () => { + const bigSize = 50_000_000; + const messages: ChatRequest["messages"] = [ + { role: "user", content: [{ type: "audio", attachmentId: "audio1", mimeType: "audio/mpeg" }] }, + ]; + const sizes = new Map([["audio1", bigSize]]); + const estimate = estimateRequestTokens(messages, [], sizes, VISION_MODEL); + expect(estimate).toBeLessThan(bigSize); + }); + + it("普通 100KB 照片不应被判定为超出未配置 contextWindow 模型(128K)的输入预算(finding 8)", () => { + const messages: ChatRequest["messages"] = [ + { role: "user", content: "帮我看看这张截图" }, + { role: "user", content: [{ type: "image", attachmentId: "screenshot", mimeType: "image/png" }] }, + ]; + const sizes = new Map([["screenshot", 100_000]]); + // VISION_MODEL 未显式配置 contextWindow,按 gpt-4o 前缀推断为 128_000 + const estimate = estimateRequestTokens(messages, [], sizes, VISION_MODEL); + // 128_000 * 0.9 的预检阈值 ≈ 115_200;一张普通照片折算后的 token 数应远低于这个预算, + // 而不是像按 1 字节 = 1 token 估算那样膨胀到十几万 token 直接把预算撑爆 + expect(estimate).toBeLessThan(115_200 * 0.5); + }); +}); + +describe("estimateRequestTokens 的按消息缓存不应产生陈旧结果", () => { + it("elideOldToolResults 原地改写 content 后,同一批 message 对象的估算值应立即反映裁剪后的内容", () => { + const messages: ChatRequest["messages"] = [ + { role: "user", content: "你好" }, + { + role: "assistant", + content: "", + toolCalls: [{ id: "c1", name: "tool", arguments: "{}", status: "completed" }], + }, + { role: "tool", content: "x".repeat(5000), toolCallId: "c1" }, + ]; + + const before = estimateRequestTokens(messages); + // keepLastAssistantTurns=0 会把所有 tool 结果裁剪为占位文本 + elideOldToolResults(messages, 0); + const after = estimateRequestTokens(messages); + + expect(after).toBeLessThan(before); + expect(messages[2].content).toBe(ELIDED_TOOL_RESULT_STUB); + }); + + it("elideOldAttachments 原地改写 content 后,估算值应立即反映占位后的内容", () => { + const messages: ChatRequest["messages"] = [ + { role: "user", content: [{ type: "image", attachmentId: "img1", mimeType: "image/png" }] }, + { role: "user", content: "占位" }, + { role: "user", content: "占位" }, + ]; + const sizes = new Map([["img1", 6_000_000]]); + + const before = estimateRequestTokens(messages, [], sizes, VISION_MODEL); + elideOldAttachments(messages, 2); + const after = estimateRequestTokens(messages, [], sizes, VISION_MODEL); + + expect(after).toBeLessThan(before); + expect(messages[0].content).toEqual([{ type: "text", text: expect.stringContaining("attachment elided") }]); + }); + + it("assistant 消息的 toolCalls.status 在工具执行后原地变化时,估算值应反映最新状态而非缓存旧值", () => { + const toolCall: ToolCall = { id: "c1", name: "tool", arguments: "{}", status: "running" }; + const messages: ChatRequest["messages"] = [{ role: "assistant", content: "", toolCalls: [toolCall] }]; + + const runningEstimate = estimateRequestTokens(messages); + // 工具执行完成后原地回写 status(tool_loop_orchestrator_base.ts 的 applyToolUpdates 同款操作) + toolCall.status = "completed"; + toolCall.result = "a longer completed result string that changes the byte size"; + const completedEstimate = estimateRequestTokens(messages); + + expect(completedEstimate).toBeGreaterThan(runningEstimate); + }); + + it("反复调用应返回稳定一致的结果(缓存命中不改变估算值)", () => { + const messages: ChatRequest["messages"] = [ + { role: "user", content: "稳定内容".repeat(100) }, + { role: "assistant", content: "回复内容".repeat(100) }, + ]; + + const first = estimateRequestTokens(messages); + const second = estimateRequestTokens(messages); + const third = estimateRequestTokens(messages); + + expect(second).toBe(first); + expect(third).toBe(first); + }); +}); diff --git a/src/app/service/agent/core/context_elision.ts b/src/app/service/agent/core/context_elision.ts new file mode 100644 index 000000000..1568e542b --- /dev/null +++ b/src/app/service/agent/core/context_elision.ts @@ -0,0 +1,203 @@ +// 滑动窗口裁剪:仅裁剪内存中传给 LLM 的 messages(不影响 chatRepo 持久化/UI 历史), +// 用于在触及 autoCompact 的 80% 阈值之前,减少长 tool loop 中旧 tool 结果的重复计费。 +import type { AgentModelConfig, ChatRequest, ContentBlock } from "./types"; +import { supportsVision } from "./model_capabilities"; +import { imageBlockFallbackText } from "./providers/content_utils"; + +export const ELIDED_TOOL_RESULT_STUB = + "[tool result elided to save context — re-run the tool if you need this data again]"; + +/** 附件裁剪占位文本:保留 type/attachmentId(OPFS 路径),供模型按需重新读取,而非丢弃全部标识信息。 */ +function elidedAttachmentStub(block: Exclude): string { + return `[attachment elided to save context, type: ${block.type}, OPFS path: uploads/${block.attachmentId} — re-open the attachment if needed]`; +} + +/** 读取 provider 会把附件展开成 data URL 后的实际字节规模。 */ +export async function loadAttachmentSizes( + messages: ChatRequest["messages"], + getAttachment: (id: string) => Promise +): Promise> { + const ids = new Set(); + for (const message of messages) { + if (!Array.isArray(message.content)) continue; + for (const block of message.content) { + if (block.type !== "text") ids.add(block.attachmentId); + } + } + const sizes = new Map(); + await Promise.all( + [...ids].map(async (id) => { + try { + const blob = await getAttachment(id); + if (blob) sizes.set(id, blob.size); + } catch { + // 无法读取的附件保留为未知大小,由预算检查决定是否省略或拒绝请求。 + } + }) + ); + return sizes; +} + +// 字节→token 的保守换算:常见 tokenizer 在 UTF-8 文本上很少低于 2 字节/token +// (英文/Latin 文本通常 ~4 字节/token,CJK 每字符 3 字节通常对应 1-2 token)。 +// 直接把字节数当 token 数会比真实 token 数偏大数倍,导致远低于模型真实上限就被裁剪/拒绝; +// 除以该常量后仍保持保守(不会低估),同时不再把每个 UTF-8 字节当作独立 token。 +const CONSERVATIVE_BYTES_PER_TOKEN = 2; + +// 每条 message 的 role/content/toolCallId 序列化字节数缓存。 +// tool loop 每轮都要重新估算整段 messages 的预算占用;不缓存的话,每轮都要把(随对话增长的) +// 完整历史重新 JSON.stringify 一遍,R 轮下来是 O(R·N) —— 长对话里最耗时的部分。 +// 而 content/toolCallId 一旦写入某条 message 就几乎不再变化,唯二的例外是 +// elideOldToolResults / elideOldAttachments 原地改写 m.content,这两处都会显式调用 +// invalidateMessageByteCache() 使缓存失效;因此按 message 对象身份缓存是安全的。 +// toolCalls 字段(status/attachments/subAgentDetails 会在工具执行后原地变化)不缓存, +// 每次都单独重算——但它只随"该条消息自身的工具调用数"增长,不随对话长度增长,代价很小。 +const stableContentByteCache = new WeakMap(); + +/** 消息 content 被原地改写后必须调用,否则会读到改写前的缓存字节数。 */ +export function invalidateMessageByteCache(message: object): void { + stableContentByteCache.delete(message); +} + +function getStableContentBytes(message: { role: string; content: unknown; toolCallId?: string }): number { + let bytes = stableContentByteCache.get(message); + if (bytes === undefined) { + const stable: Record = { role: message.role, content: message.content }; + if (message.toolCallId) stable.toolCallId = message.toolCallId; + bytes = new TextEncoder().encode(JSON.stringify(stable)).byteLength; + stableContentByteCache.set(message, bytes); + } + return bytes; +} + +// vision 图片的字节→token 保守换算。真实 provider 计费和 base64 字节数没有线性对应关系 +// (OpenAI 按分块计费,一张图约 85~1105 token;Anthropic 按 宽×高/750,一张 1920×1080 照片 +// 约 3686 token),典型压缩照片的 base64 体积换算下来大约是每 100~300 字节 1 token。 +// 本仓库在附件加载阶段还没有解出图片宽高(获取宽高需要解码,见 finding 8 的后续待办), +// 因此这里用一个远比真实比例保守的固定换算:每 40 字节算 1 token——比真实比例保守 +// 2.5~7.5 倍,不会把图片开销算得比实际便宜,但也不会把一张普通照片(几十到一百多 KB) +// 的开销放大到几万甚至十几万 token 从而把正常大小的截图/照片直接拒绝在预算之外。 +const IMAGE_CONSERVATIVE_BYTES_PER_TOKEN = 40; + +/** + * 请求 token 的启发式估算,不是任何 provider 的精确 tokenizer(本仓库未接入 + * tiktoken/Anthropic 官方计数器,引入新依赖超出当前改动范围)。分两部分独立估算, + * 因为文本/JSON 与 vision 图片的字节→token 比例机制完全不同,不应共用同一个换算系数: + * + * - 文本 + JSON 结构(messages/tools/toolCalls):按 UTF-8 字节数 / CONSERVATIVE_BYTES_PER_TOKEN + * 保守折算。这仍然是启发式而非保证:高熵内容或极端 tokenizer 差异下仍可能被低估, + * 调用方(preflight 预算检查、elideUntilWithinBudget)应把结果当作"大致上界"而非精确值。 + * - vision 图片:按 IMAGE_CONSERVATIVE_BYTES_PER_TOKEN 折算(见上方常量注释),而不是直接把 + * base64 字节数当 token 数——后者会让一张普通照片的估算膨胀到几万 token,超出未配置 + * contextWindow 的模型(如 128K)的输入预算,把正常截图/照片当作"超出上下文"拒绝掉。 + * + * 只对 provider 实际会内联展开为 base64 的块计入二进制字节: + * 当前仅 vision 模型的 image 块会被 resolveAttachments 加载;file/audio 及非 vision 模型的 + * image 均降级为纯文本描述(见 providers/content_utils.ts),其体积已包含在下方的 JSON 基线字节里。 + */ +export function estimateRequestTokens( + messages: ChatRequest["messages"], + tools?: unknown[], + attachmentSizes?: Map, + model?: AgentModelConfig +): number { + const hasVision = model ? supportsVision(model) : false; + const attachmentTokens = messages.reduce((sum, message) => { + if (!Array.isArray(message.content)) return sum; + return ( + sum + + message.content.reduce((blockSum, block) => { + if (block.type === "text") return blockSum; + // file/audio 从不被内联;image 只在 vision 模型上才会被解析为 data URL + if (block.type === "file" || block.type === "audio" || !hasVision) return blockSum; + const size = attachmentSizes?.get(block.attachmentId); + if (size == null) { + // 附件大小未知时降级为纯文本描述——这部分本身就是文本,按文本换算系数折算, + // 不能套用图片换算系数(那是给真实二进制图片数据用的) + return ( + blockSum + + Math.ceil(new TextEncoder().encode(imageBlockFallbackText(block)).byteLength / CONSERVATIVE_BYTES_PER_TOKEN) + ); + } + const base64Bytes = Math.ceil(size / 3) * 4 + 128; + return blockSum + Math.ceil(base64Bytes / IMAGE_CONSERVATIVE_BYTES_PER_TOKEN); + }, 0) + ); + }, 0); + if (!Number.isFinite(attachmentTokens)) return Number.POSITIVE_INFINITY; + + let bytes = 0; + for (const message of messages) { + bytes += getStableContentBytes(message); + if (message.toolCalls && message.toolCalls.length > 0) { + bytes += new TextEncoder().encode(JSON.stringify(message.toolCalls)).byteLength; + } + } + if (tools && tools.length > 0) { + bytes += new TextEncoder().encode(JSON.stringify(tools)).byteLength; + } + // 文本/JSON 部分按文本换算系数折算;图片部分已经在上面按图片换算系数折算成 token,两者相加 + return Math.ceil(bytes / CONSERVATIVE_BYTES_PER_TOKEN) + attachmentTokens; +} + +/** + * 保留最近 keepLastAssistantTurns 轮 assistant(带 toolCalls) 及其后的消息原文, + * 将更早的 tool 角色消息内容替换为占位文本。 + */ +export function elideOldToolResults(messages: ChatRequest["messages"], keepLastAssistantTurns: number): void { + let assistantTurnsSeen = 0; + let cutoffIndex = -1; + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]; + if (m.role === "assistant" && m.toolCalls && m.toolCalls.length > 0) { + assistantTurnsSeen++; + if (assistantTurnsSeen === keepLastAssistantTurns) { + cutoffIndex = i; + break; + } + } + } + + const limit = keepLastAssistantTurns === 0 ? messages.length : cutoffIndex; + for (let i = 0; i < limit; i++) { + const m = messages[i]; + if (m.role === "tool" && m.content !== ELIDED_TOOL_RESULT_STUB) { + m.content = ELIDED_TOOL_RESULT_STUB; + invalidateMessageByteCache(m); + } + } +} + +/** 将较旧消息中的多模态块替换为可恢复的文本占位,保留最近消息的附件。 */ +export function elideOldAttachments(messages: ChatRequest["messages"], keepLastMessages = 2): void { + const cutoff = Math.max(0, messages.length - keepLastMessages); + for (let i = 0; i < cutoff; i++) { + const message = messages[i]; + if (!Array.isArray(message.content)) continue; + message.content = message.content.map((block) => + block.type === "text" ? block : { type: "text", text: elidedAttachmentStub(block) } + ); + invalidateMessageByteCache(message); + } +} + +/** 在安全预算内尽量保留最近的 tool 结果;必要时裁剪全部旧结果。 */ +export function elideUntilWithinBudget( + messages: ChatRequest["messages"], + contextWindow: number, + tools?: unknown[], + budgetRatio = 0.9, + attachmentSizes?: Map, + model?: AgentModelConfig +): boolean { + // 原先按 hasToolResults 分两条 if 判断,但两分支条件完全相同(结果都只取决于 estimate()), + // 白白多做一次 O(n) 的 messages.some() 扫描;合并为一次判断,语义不变。 + const estimate = () => estimateRequestTokens(messages, tools, attachmentSizes, model); + if (estimate() / contextWindow < budgetRatio) return true; + for (let keep = 5; keep >= 0; keep--) { + elideOldToolResults(messages, keep); + if (estimate() / contextWindow < budgetRatio) return true; + } + elideOldAttachments(messages); + return estimate() / contextWindow < budgetRatio; +} diff --git a/src/app/service/agent/core/mcp_client.ts b/src/app/service/agent/core/mcp_client.ts index dcb15ad5d..31df8d1ee 100644 --- a/src/app/service/agent/core/mcp_client.ts +++ b/src/app/service/agent/core/mcp_client.ts @@ -63,12 +63,16 @@ export class MCPClient { })); } - async callTool(name: string, args?: Record): Promise { + async callTool(name: string, args?: Record, signal?: AbortSignal): Promise { this.ensureInitialized(); - const result = (await this.sendRequest("tools/call", { - name, - arguments: args || {}, - })) as { + const result = (await this.sendRequest( + "tools/call", + { + name, + arguments: args || {}, + }, + signal + )) as { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; isError?: boolean; }; @@ -183,7 +187,7 @@ export class MCPClient { return headers; } - async sendRequest(method: string, params?: Record): Promise { + async sendRequest(method: string, params?: Record, signal?: AbortSignal): Promise { const id = this.nextId++; const body: JsonRpcRequest = { jsonrpc: "2.0", @@ -196,7 +200,7 @@ export class MCPClient { method: "POST", headers: this.buildHeaders(), body: JSON.stringify(body), - signal: AbortSignal.timeout(60_000), + signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(60_000)]) : AbortSignal.timeout(60_000), }); // 存储 session ID diff --git a/src/app/service/agent/core/mcp_tool_executor.test.ts b/src/app/service/agent/core/mcp_tool_executor.test.ts index d01415e63..93c12a7e5 100644 --- a/src/app/service/agent/core/mcp_tool_executor.test.ts +++ b/src/app/service/agent/core/mcp_tool_executor.test.ts @@ -16,7 +16,7 @@ describe("MCPToolExecutor", () => { const result = await executor.execute({ query: "hello" }); expect(result).toBe("tool result"); - expect(client.callTool).toHaveBeenCalledWith("search", { query: "hello" }); + expect(client.callTool).toHaveBeenCalledWith("search", { query: "hello" }, undefined); }); it("应正确传递工具名", async () => { @@ -26,7 +26,7 @@ describe("MCPToolExecutor", () => { const result = await executor.execute({ limit: 10 }); expect(result).toEqual({ data: [1, 2, 3] }); - expect(client.callTool).toHaveBeenCalledWith("fetch_data", { limit: 10 }); + expect(client.callTool).toHaveBeenCalledWith("fetch_data", { limit: 10 }, undefined); }); it("callTool 抛出异常时应向上传播", async () => { diff --git a/src/app/service/agent/core/mcp_tool_executor.ts b/src/app/service/agent/core/mcp_tool_executor.ts index 88dbafc6d..cc374dc3e 100644 --- a/src/app/service/agent/core/mcp_tool_executor.ts +++ b/src/app/service/agent/core/mcp_tool_executor.ts @@ -9,8 +9,8 @@ export class MCPToolExecutor implements ToolExecutor { private toolName: string ) {} - async execute(args: Record): Promise { - const result = await this.client.callTool(this.toolName, args); + async execute(args: Record, signal?: AbortSignal): Promise { + const result = await this.client.callTool(this.toolName, args, signal); // 检测 MCP 返回的 content 数组是否包含 image 类型 if (Array.isArray(result)) { diff --git a/src/app/service/agent/core/model_context.test.ts b/src/app/service/agent/core/model_context.test.ts index 39d0e8085..98def1806 100644 --- a/src/app/service/agent/core/model_context.test.ts +++ b/src/app/service/agent/core/model_context.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { getContextWindow, inferContextWindow, DEFAULT_CONTEXT_WINDOW } from "./model_context"; +import { + getContextWindow, + getInputTokenBudget, + inferContextWindow, + normalizeModelLimits, + DEFAULT_CONTEXT_WINDOW, +} from "./model_context"; describe("getContextWindow", () => { it("returns user-configured contextWindow when provided", () => { @@ -53,6 +59,44 @@ describe("getContextWindow", () => { it("returns default for unknown models", () => { expect(getContextWindow({ model: "my-custom-model" })).toBe(DEFAULT_CONTEXT_WINDOW); }); + + it("负数 contextWindow 是 truthy,不能被原样返回,否则 getInputTokenBudget 会塌缩为 0(finding 10)", () => { + expect(getContextWindow({ model: "gpt-4o", contextWindow: -1 })).toBe(128_000); + expect(getInputTokenBudget({ model: "gpt-4o", contextWindow: -1, provider: "openai" } as any)).toBeGreaterThan(0); + }); + + it("非有限数 contextWindow 应回退到前缀匹配", () => { + expect(getContextWindow({ model: "gpt-4o", contextWindow: Infinity })).toBe(128_000); + expect(getContextWindow({ model: "gpt-4o", contextWindow: NaN })).toBe(128_000); + }); +}); + +describe("normalizeModelLimits(finding 10:存储边界统一归一化)", () => { + it("负数 / 非有限数 / 超出合理范围一律归一化为 undefined", () => { + expect(normalizeModelLimits({ maxTokens: -5, contextWindow: -1 })).toEqual({ + maxTokens: undefined, + contextWindow: undefined, + }); + expect(normalizeModelLimits({ maxTokens: Infinity, contextWindow: NaN })).toEqual({ + maxTokens: undefined, + contextWindow: undefined, + }); + expect(normalizeModelLimits({ maxTokens: 50_000_000, contextWindow: 50_000_000 })).toEqual({ + maxTokens: undefined, + contextWindow: undefined, + }); + }); + + it("合法正整数保持不变(向下取整)", () => { + expect(normalizeModelLimits({ maxTokens: 4096.7, contextWindow: 128_000 })).toEqual({ + maxTokens: 4096, + contextWindow: 128_000, + }); + }); + + it("未配置(undefined)保持 undefined,交给下游默认值", () => { + expect(normalizeModelLimits({})).toEqual({ maxTokens: undefined, contextWindow: undefined }); + }); }); describe("inferContextWindow", () => { diff --git a/src/app/service/agent/core/model_context.ts b/src/app/service/agent/core/model_context.ts index 46ea4e748..03f898c88 100644 --- a/src/app/service/agent/core/model_context.ts +++ b/src/app/service/agent/core/model_context.ts @@ -1,3 +1,5 @@ +import type { AgentModelConfig } from "./types"; + // 模型上下文窗口大小映射表 // [前缀, 上下文窗口大小],按前缀长度降序排列以确保最精确匹配优先 const MODEL_CONTEXT_PREFIXES: Array<[string, number]> = [ @@ -41,10 +43,16 @@ const MODEL_CONTEXT_PREFIXES: Array<[string, number]> = [ ]; export const DEFAULT_CONTEXT_WINDOW = 128_000; +export const DEFAULT_ANTHROPIC_MAX_TOKENS = 16_384; +export const CONTEXT_SAFETY_MARGIN_RATIO = 0.1; /** 获取模型的上下文窗口大小,优先使用用户配置,否则按前缀匹配 */ export function getContextWindow(config: { model: string; contextWindow?: number }): number { - if (config.contextWindow) return config.contextWindow; + // > 0 而非直接 truthy 判断:负数是 truthy,会原样返回并让 getInputTokenBudget() 的预算计算 + // 塌缩为 0(见 finding 10) + if (typeof config.contextWindow === "number" && Number.isFinite(config.contextWindow) && config.contextWindow > 0) { + return config.contextWindow; + } const modelLower = config.model.toLowerCase(); for (const [prefix, size] of MODEL_CONTEXT_PREFIXES) { if (modelLower.startsWith(prefix)) return size; @@ -52,6 +60,34 @@ export function getContextWindow(config: { model: string; contextWindow?: number return DEFAULT_CONTEXT_WINDOW; } +/** + * 获取 provider 实际会请求的最大输出 token 数。 + * 未显式配置 maxTokens 时,不能按 0 预留:OpenAI 兼容请求体在这种情况下会直接省略 + * max_tokens 字段(见 providers/openai.ts),provider 侧会套用它自己的默认输出上限 + * (通常有实质数值,不是 0)。这里统一用一个有记录的保守默认值兜底,而不是假装输出不占预算, + * 否则输入预算会把整个 contextWindow 都算给输入,实际请求的 输入+输出 可能超出真实上限 + * (见 finding 11)。 + */ +export function getReservedOutputTokens(config: AgentModelConfig): number { + const contextWindow = getContextWindow(config); + const configured = + typeof config.maxTokens === "number" && Number.isFinite(config.maxTokens) + ? Math.max(0, Math.floor(config.maxTokens)) + : 0; + const requested = configured > 0 ? configured : DEFAULT_ANTHROPIC_MAX_TOKENS; + return Math.min(contextWindow, requested); +} + +/** + * 发送请求前允许输入占用的最大 token 预算。 + * contextWindow 是输入与输出的总和,因此需同时预留 provider 请求的输出额度和安全边际。 + */ +export function getInputTokenBudget(config: AgentModelConfig): number { + const contextWindow = getContextWindow(config); + const safetyMargin = Math.ceil(contextWindow * CONTEXT_SAFETY_MARGIN_RATIO); + return Math.max(0, contextWindow - getReservedOutputTokens(config) - safetyMargin); +} + /** 根据模型名称推断上下文窗口大小(不考虑用户配置) */ export function inferContextWindow(model: string): number { const modelLower = model.toLowerCase(); @@ -60,3 +96,29 @@ export function inferContextWindow(model: string): number { } return DEFAULT_CONTEXT_WINDOW; } + +// 允许的 token 数上限:远超已知最大模型(Gemini/GPT-4.1 约 1M),为未来更大模型留余量, +// 同时拒绝明显异常的值(Infinity、Number.MAX_SAFE_INTEGER 等) +const MAX_REASONABLE_TOKEN_LIMIT = 10_000_000; + +/** + * 把用户可能填入的 maxTokens/contextWindow 归一化为有限正整数(或 undefined,交给下游默认值)。 + * 非有限数、非正数、超出合理范围一律视为未配置——防止负数因为 JS 的 truthy 判断被当作"已配置" + * 直接发给 provider(如 Anthropic 的 max_tokens = config.maxTokens || 16384 会把负数原样发出), + * 也防止负的 contextWindow 让 getInputTokenBudget() 的预算计算塌缩为 0(见 finding 10)。 + */ +function normalizeTokenLimit(value: number | undefined): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) return undefined; + const normalized = Math.floor(value); + if (normalized <= 0 || normalized > MAX_REASONABLE_TOKEN_LIMIT) return undefined; + return normalized; +} + +/** 在模型配置持久化前统一归一化 maxTokens/contextWindow,作为存储边界的唯一校验点。 */ +export function normalizeModelLimits(config: T): T { + return { + ...config, + maxTokens: normalizeTokenLimit(config.maxTokens), + contextWindow: normalizeTokenLimit(config.contextWindow), + }; +} diff --git a/src/app/service/agent/core/opfs_helpers.ts b/src/app/service/agent/core/opfs_helpers.ts index 452110751..88ac18f77 100644 --- a/src/app/service/agent/core/opfs_helpers.ts +++ b/src/app/service/agent/core/opfs_helpers.ts @@ -1,6 +1,8 @@ // OPFS 工作区公共辅助函数 // 供 opfs_tools、agent_dom 等模块复用 +import { throwIfAborted } from "./abort_utils"; + export const WORKSPACE_ROOT = "agents/workspace"; /** Strip leading `/`, reject `..` segments */ @@ -19,20 +21,24 @@ export function sanitizePath(raw: string): string { export async function getDirectory( root: FileSystemDirectoryHandle, path: string, - create = false + create = false, + signal?: AbortSignal ): Promise { const segments = path.split("/").filter(Boolean); let dir = root; for (const seg of segments) { + throwIfAborted(signal); dir = await dir.getDirectoryHandle(seg, { create }); } return dir; } /** Get the workspace root directory handle */ -export async function getWorkspaceRoot(create = false): Promise { +export async function getWorkspaceRoot(create = false, signal?: AbortSignal): Promise { + throwIfAborted(signal); const opfsRoot = await navigator.storage.getDirectory(); - return getDirectory(opfsRoot, WORKSPACE_ROOT, create); + throwIfAborted(signal); + return getDirectory(opfsRoot, WORKSPACE_ROOT, create, signal); } /** Split a sanitized path into parent directory path and filename */ @@ -71,8 +77,10 @@ export function isDataUrl(str: string): boolean { /** 将二进制数据写入 OPFS workspace 指定路径 */ export async function writeWorkspaceFile( path: string, - data: Uint8Array | Blob | string + data: Uint8Array | Blob | string, + signal?: AbortSignal ): Promise<{ path: string; size: number }> { + throwIfAborted(signal); const safePath = sanitizePath(path); if (!safePath) throw new Error("path is required"); @@ -82,21 +90,33 @@ export async function writeWorkspaceFile( data = decoded.data; } - const workspace = await getWorkspaceRoot(true); + const workspace = await getWorkspaceRoot(true, signal); const { dirPath, fileName } = splitPath(safePath); - const dir = dirPath ? await getDirectory(workspace, dirPath, true) : workspace; + const dir = dirPath ? await getDirectory(workspace, dirPath, true, signal) : workspace; + throwIfAborted(signal); const fileHandle = await dir.getFileHandle(fileName, { create: true }); + throwIfAborted(signal); const writable = await fileHandle.createWritable(); - - if (data instanceof Blob) { - await writable.write(data); - } else if (data instanceof Uint8Array) { - // 精确截取视图对应的字节段,避免切片视图写入整个底层 buffer - await writable.write((data.buffer as ArrayBuffer).slice(data.byteOffset, data.byteOffset + data.byteLength)); - } else { - await writable.write(data); + try { + throwIfAborted(signal); + if (data instanceof Blob) { + await writable.write(data); + } else if (data instanceof Uint8Array) { + // 精确截取视图对应的字节段,避免切片视图写入整个底层 buffer + await writable.write((data.buffer as ArrayBuffer).slice(data.byteOffset, data.byteOffset + data.byteLength)); + } else { + await writable.write(data); + } + throwIfAborted(signal); + await writable.close(); + } catch (error) { + try { + await writable.abort(); + } catch { + // 写入流已经关闭或终止时无需额外处理 + } + throw error; } - await writable.close(); let size: number; if (data instanceof Blob) { diff --git a/src/app/service/agent/core/persisted_messages.ts b/src/app/service/agent/core/persisted_messages.ts new file mode 100644 index 000000000..c68d7dca8 --- /dev/null +++ b/src/app/service/agent/core/persisted_messages.ts @@ -0,0 +1,15 @@ +import type { ChatMessage, ChatRequest } from "./types"; + +/** 将持久化消息转换为 LLM 消息,错误占位消息只用于 UI 展示,不应重放。 */ +export function toLLMMessages( + messages: Array> +): ChatRequest["messages"] { + return messages + .filter((message) => !message.error) + .map((message) => ({ + role: message.role, + content: message.content, + toolCallId: message.toolCallId, + toolCalls: message.toolCalls, + })); +} diff --git a/src/app/service/agent/core/providers/anthropic.test.ts b/src/app/service/agent/core/providers/anthropic.test.ts index 36697e7b9..d905fbea0 100644 --- a/src/app/service/agent/core/providers/anthropic.test.ts +++ b/src/app/service/agent/core/providers/anthropic.test.ts @@ -170,6 +170,95 @@ describe("buildAnthropicRequest", () => { expect(body.system[0].cache_control).toBeUndefined(); // tool 不应有 cache_control expect(body.tools[0].cache_control).toBeUndefined(); + // 消息历史也不应有 cache_control + expect(body.messages[0].content).toBe("hi"); + }); + + describe("消息历史的 cache_control 断点(用于长 tool loop 中复用已缓存前缀)", () => { + it("最后一条纯文本消息应转换为带 cache_control 的 text block", () => { + const request: ChatRequest = { + conversationId: "c1", + modelId: "test", + messages: [ + { role: "user", content: "第一句" }, + { role: "assistant", content: "第一句回复" }, + { role: "user", content: "最后一句" }, + ], + }; + + const { init } = buildAnthropicRequest(config, request); + const body = JSON.parse(init.body as string); + + // 非最后一条消息保持原样(字符串),不应被转换 + expect(body.messages[0].content).toBe("第一句"); + expect(body.messages[1].content).toBe("第一句回复"); + // 最后一条转换为带 cache_control 的 text block + expect(body.messages[2].content).toEqual([ + { type: "text", text: "最后一句", cache_control: { type: "ephemeral" } }, + ]); + }); + + it("最后一条消息已是 tool_result content block 时应在最后一个 block 上加 cache_control", () => { + const request: ChatRequest = { + conversationId: "c1", + modelId: "test", + messages: [ + { role: "user", content: "天气" }, + { + role: "assistant", + content: "让我查一下", + toolCalls: [{ id: "toolu_1", name: "get_weather", arguments: "{}" }], + }, + { role: "tool", content: '{"temp":25}', toolCallId: "toolu_1" }, + ], + }; + + const { init } = buildAnthropicRequest(config, request); + const body = JSON.parse(init.body as string); + + const lastMsg = body.messages[body.messages.length - 1]; + expect(lastMsg.role).toBe("user"); + expect(lastMsg.content[0].type).toBe("tool_result"); + expect(lastMsg.content[0].cache_control).toEqual({ type: "ephemeral" }); + }); + + it("cache: false 时最后一条消息不应转换或添加 cache_control", () => { + const request: ChatRequest = { + conversationId: "c1", + modelId: "test", + messages: [{ role: "user", content: "最后一句" }], + cache: false, + }; + + const { init } = buildAnthropicRequest(config, request); + const body = JSON.parse(init.body as string); + + expect(body.messages[0].content).toBe("最后一句"); + }); + + it("最后一条消息内容为空字符串时不应添加 cache_control(无内容块可挂载)", () => { + const request: ChatRequest = { + conversationId: "c1", + modelId: "test", + messages: [ + { role: "user", content: "天气" }, + { + role: "assistant", + content: "", + toolCalls: [{ id: "toolu_1", name: "get_weather", arguments: "{}" }], + }, + ], + }; + + const { init } = buildAnthropicRequest(config, request); + const body = JSON.parse(init.body as string); + + const lastMsg = body.messages[body.messages.length - 1]; + // 仅有 tool_use block,没有额外的空 text block + expect(lastMsg.content).toHaveLength(1); + expect(lastMsg.content[0].type).toBe("tool_use"); + expect(lastMsg.content[0].cache_control).toEqual({ type: "ephemeral" }); + }); }); it("默认 max_tokens 为 16384,应设置 stream", () => { @@ -201,6 +290,7 @@ describe("buildAnthropicRequest", () => { messages: [ { role: "user", content: "hi" }, { role: "tool", content: "result" }, // 无 toolCallId + { role: "user", content: "继续" }, // 占位,避免上一条被当作最后一条消息加上 cache_control ], }; @@ -388,7 +478,7 @@ describe("parseAnthropicStream", () => { } }); - it("signal 已中止时应停止读取", async () => { + it("signal 已中止时应停止读取并 reject,而不是静默 resolve(否则外层 callLLM 永远挂起)", async () => { const controller = new AbortController(); controller.abort(); @@ -397,11 +487,51 @@ describe("parseAnthropicStream", () => { ]); const events: ChatStreamEvent[] = []; - await parseAnthropicStream(reader, (e) => events.push(e), controller.signal); + await expect(parseAnthropicStream(reader, (e) => events.push(e), controller.signal)).rejects.toThrow("Aborted"); expect(events).toHaveLength(0); }); + it("abort 前已经收到过 message_start usage 时,reject 的错误应携带这部分已知 usage(finding 6)", async () => { + const controller = new AbortController(); + const encoder = new TextEncoder(); + let index = 0; + const chunks = [ + 'event: message_start\ndata: {"message":{"usage":{"input_tokens":20,"cache_read_input_tokens":5}}}\n\n', + ]; + const reader = { + read: async () => { + if (index < chunks.length) { + return { done: false, value: encoder.encode(chunks[index++]) }; + } + controller.abort(); + throw new Error("Aborted"); + }, + cancel: async () => {}, + closed: Promise.resolve(undefined), + releaseLock: () => {}, + } as any; + + const events: ChatStreamEvent[] = []; + await expect(parseAnthropicStream(reader, (e) => events.push(e), controller.signal)).rejects.toMatchObject({ + message: "Aborted", + usage: { inputTokens: 20, outputTokens: 0, cacheReadInputTokens: 5 }, + }); + }); + + it("流正常结束但没有 message_stop/message_delta(usage)/error 终态帧时应补发 error,而不是静默完成", async () => { + // 只有 content_block_delta,reader 提前 done,模拟连接在终态帧之前被服务端关闭 + const reader = createMockReader([ + 'event: content_block_delta\ndata: {"delta":{"type":"text_delta","text":"hi"}}\n\n', + ]); + + const events: ChatStreamEvent[] = []; + const controller = new AbortController(); + await parseAnthropicStream(reader, (e) => events.push(e), controller.signal); + + expect(events.some((e) => e.type === "error")).toBe(true); + }); + it("读取错误时应发送 error 事件", async () => { const reader = { read: async () => { @@ -424,7 +554,7 @@ describe("parseAnthropicStream", () => { } }); - it("读取错误但 signal 已中止时不应发送 error", async () => { + it("读取错误但 signal 已中止时应 reject 而不是发送 error", async () => { const controller = new AbortController(); const reader = { read: async () => { @@ -437,7 +567,7 @@ describe("parseAnthropicStream", () => { } as any; const events: ChatStreamEvent[] = []; - await parseAnthropicStream(reader, (e) => events.push(e), controller.signal); + await expect(parseAnthropicStream(reader, (e) => events.push(e), controller.signal)).rejects.toThrow("Aborted"); expect(events).toHaveLength(0); }); diff --git a/src/app/service/agent/core/providers/anthropic.ts b/src/app/service/agent/core/providers/anthropic.ts index 27486e164..2a488dbca 100644 --- a/src/app/service/agent/core/providers/anthropic.ts +++ b/src/app/service/agent/core/providers/anthropic.ts @@ -1,6 +1,7 @@ import type { ChatStreamEvent, ChatRequest, ContentBlock } from "../types"; import type { AgentModelConfig } from "../types"; import { isContentBlocks } from "../content_utils"; +import { getReservedOutputTokens } from "../model_context"; import { generateAttachmentId, convertTextBlock, @@ -117,13 +118,30 @@ export function buildAnthropicRequest( return { role: m.role, content: m.content }; }); + // 最后一条消息追加 cache_control:使本轮完整历史被缓存。 + // 下一轮请求中该消息不再是最后一条,但 Anthropic 按前缀匹配复用已缓存部分, + // 只需为新增的增量消息计费,从而避免长 tool loop 下每轮都全量计费输入 token。 + if (useCache && messages.length > 0) { + const lastMessage = messages[messages.length - 1]; + const content: unknown = lastMessage.content; + if (Array.isArray(content) && content.length > 0) { + (content[content.length - 1] as Record).cache_control = { type: "ephemeral" }; + } else if (typeof content === "string" && content.length > 0) { + (lastMessage as { content: unknown }).content = [ + { type: "text", text: content, cache_control: { type: "ephemeral" } }, + ]; + } + } + const body: Record = { model: config.model, messages, stream: true, }; - body.max_tokens = config.maxTokens || 16384; + // 用归一化后的 getReservedOutputTokens 而不是 config.maxTokens || 16384: + // 负数在 JS 里是 truthy,会绕过 || 兜底原样发给 provider(见 finding 10) + body.max_tokens = getReservedOutputTokens(config) || 16384; if (systemMessages.length > 0) { const systemBlocks = systemMessages.map((m) => ({ @@ -187,6 +205,10 @@ export function parseAnthropicStream( // 跟踪图片块的累积 base64 数据 let imageBlockData: { index: number; mediaType: string; base64Chunks: string[] } | null = null; + // 标记是否已发出终态事件(message_stop / message_delta 带 usage / error), + // 避免连接在收到终态帧前就断开时,调用方(LLMClient.callLLM)的外层 Promise 永远不 settle + let doneSent = false; + const toolUseByIndex = new Map(); return readSSEStream( @@ -288,6 +310,7 @@ export function parseAnthropicStream( case "message_delta": { // 消息结束,合并 message_start 的 input usage 和 message_delta 的 output usage if (json.usage) { + doneSent = true; onEvent({ type: "done", usage: { @@ -303,10 +326,12 @@ export function parseAnthropicStream( } case "message_stop": { toolUseByIndex.clear(); + doneSent = true; onEvent({ type: "done" }); return true; } case "error": { + doneSent = true; onEvent({ type: "error", message: json.error?.message || "Anthropic API error", @@ -319,8 +344,26 @@ export function parseAnthropicStream( } return false; }, - (message) => onEvent({ type: "error", message }) - ); + (message) => { + doneSent = true; + onEvent({ type: "error", message }); + } + ) + .then(() => { + // 流正常结束(reader done)但没收到 message_stop / message_delta(usage) / error 终态帧, + // 必须补发终态事件,否则调用方的外层 Promise 会永远挂起 + if (!signal.aborted && !doneSent) { + onEvent({ type: "error", message: "Stream ended unexpectedly without a terminal frame" }); + } + }) + .catch((error) => { + // readSSEStream 只在 abort 时才 reject(见 content_utils.ts);message_start 可能已经 + // 带回了 input/cache usage,把它带在 abort 错误上,避免取消时把这部分已知花费从终态 + // usage 里丢掉(见 finding 6)。outputTokens 在 message_delta 之前始终未知,记 0。 + throw Object.assign(error instanceof Error ? error : new Error(String(error)), { + usage: cachedUsage ? { ...cachedUsage, outputTokens: 0 } : undefined, + }); + }); } // ---- LLMProvider 接口适配 ---- diff --git a/src/app/service/agent/core/providers/content_utils.ts b/src/app/service/agent/core/providers/content_utils.ts index 69bacc3f5..ea96ec582 100644 --- a/src/app/service/agent/core/providers/content_utils.ts +++ b/src/app/service/agent/core/providers/content_utils.ts @@ -1,6 +1,7 @@ import type { ContentBlock } from "../types"; import { SSEParser } from "../sse_parser"; import type { SSEEvent } from "../sse_parser"; +import { createAbortError } from "../abort_utils"; /** * 生成图片附件 ID,格式:img_{时间戳}_{随机串}.{扩展名} @@ -26,13 +27,20 @@ export function convertFileBlock(block: Extract) }; } +/** + * image 块无法解析时的文本降级描述 + */ +export function imageBlockFallbackText(block: { name?: string; attachmentId: string }): string { + return `[Image: ${block.name || "image"}, OPFS path: uploads/${block.attachmentId}]`; +} + /** * image 块无法解析时的文本降级描述 */ export function imageBlockFallback(block: Extract): Record { return { type: "text", - text: `[Image: ${block.name || "image"}, OPFS path: uploads/${block.attachmentId}]`, + text: imageBlockFallbackText(block), }; } @@ -58,7 +66,16 @@ export async function readSSEStream( ): Promise { const parser = new SSEParser(); const decoder = new TextDecoder(); + // abort 时主动 cancel reader,唤醒可能卡在 reader.read() 上的等待 + const onAbort = () => { + reader.cancel().catch(() => {}); + }; + signal.addEventListener("abort", onAbort, { once: true }); + // onEvent 提前终止(返回 true)时,body 里可能还有未读完的数据(例如 Anthropic 在 + // message_delta 就终止本地处理,message_stop 及之后的数据从未被读取);finally 里需要 + // 据此决定是否主动 cancel 释放底层连接资源,避免 reader lock 一直占用到 GC(见 finding 12) + let earlyExit = false; try { while (!signal.aborted) { const { done, value } = await reader.read(); @@ -69,11 +86,26 @@ export async function readSSEStream( for (const sseEvent of events) { // onEvent 返回 true 代表流处理完毕,提前退出 - if (onEvent(sseEvent)) return; + if (onEvent(sseEvent)) { + earlyExit = true; + return; + } } } + // abort 导致循环退出:必须 reject 而不是静默 resolve,否则调用方(LLMClient.callLLM) + // 的外层 Promise 永远不会 settle,取消会一直挂起 + if (signal.aborted) throw createAbortError(); } catch (e: any) { - if (signal.aborted) return; + if (signal.aborted) throw createAbortError(); onError(e.message || "Stream read error"); + } finally { + signal.removeEventListener("abort", onAbort); + if (earlyExit) { + try { + await reader.cancel(); + } catch { + // 已经关闭/abort 时 cancel 可能抛错,忽略 + } + } } } diff --git a/src/app/service/agent/core/providers/openai.test.ts b/src/app/service/agent/core/providers/openai.test.ts index b145895fc..ce3e0fd83 100644 --- a/src/app/service/agent/core/providers/openai.test.ts +++ b/src/app/service/agent/core/providers/openai.test.ts @@ -157,7 +157,7 @@ describe("parseOpenAIStream", () => { it("应正确处理 usage 信息", async () => { const reader = createMockReader([ - 'data: {"choices":[{"delta":{"content":"hi"}}]}\n\n', + 'data: {"choices":[{"delta":{"content":"hi"},"finish_reason":"stop"}]}\n\n', 'data: {"usage":{"prompt_tokens":10,"completion_tokens":5}}\n\n', ]); @@ -175,7 +175,7 @@ describe("parseOpenAIStream", () => { it("应正确处理含 cached_tokens 的 usage 信息", async () => { const reader = createMockReader([ - 'data: {"choices":[{"delta":{"content":"hi"}}]}\n\n', + 'data: {"choices":[{"delta":{"content":"hi"},"finish_reason":"stop"}]}\n\n', 'data: {"usage":{"prompt_tokens":100,"completion_tokens":20,"prompt_tokens_details":{"cached_tokens":80}}}\n\n', ]); @@ -238,18 +238,46 @@ describe("parseOpenAIStream", () => { expect(events[0]).toEqual({ type: "content_delta", delta: "ok" }); }); - it("signal 已中止时应停止读取", async () => { + it("signal 已中止时应停止读取并 reject,而不是静默 resolve(否则外层 callLLM 永远挂起)", async () => { const controller = new AbortController(); controller.abort(); const reader = createMockReader(['data: {"choices":[{"delta":{"content":"hello"}}]}\n\n']); const events: ChatStreamEvent[] = []; - await parseOpenAIStream(reader, (e) => events.push(e), controller.signal); + await expect(parseOpenAIStream(reader, (e) => events.push(e), controller.signal)).rejects.toThrow("Aborted"); expect(events).toHaveLength(0); }); + it("abort 前已经收到过 usage chunk 时,reject 的错误应携带这部分已知 usage(finding 6)", async () => { + const controller = new AbortController(); + const encoder = new TextEncoder(); + let index = 0; + const chunks = [ + 'data: {"choices":[{"delta":{"content":"hi"}}],"usage":{"prompt_tokens":10,"completion_tokens":3}}\n\n', + ]; + const reader = { + read: async () => { + if (index < chunks.length) { + return { done: false, value: encoder.encode(chunks[index++]) }; + } + // 第一个带 usage 的 chunk 读取完毕后才 abort,模拟"取消发生在已经拿到部分 usage 之后" + controller.abort(); + throw new Error("Aborted"); + }, + cancel: async () => {}, + closed: Promise.resolve(undefined), + releaseLock: () => {}, + } as any; + + const events: ChatStreamEvent[] = []; + await expect(parseOpenAIStream(reader, (e) => events.push(e), controller.signal)).rejects.toMatchObject({ + message: "Aborted", + usage: { inputTokens: 10, outputTokens: 3 }, + }); + }); + it("读取错误时应发送 error 事件", async () => { const reader = { read: async () => { @@ -272,7 +300,7 @@ describe("parseOpenAIStream", () => { } }); - it("读取错误但 signal 已中止时不应发送 error 事件", async () => { + it("读取错误但 signal 已中止时应 reject 而不是发送 error 事件", async () => { const controller = new AbortController(); const reader = { read: async () => { @@ -285,7 +313,7 @@ describe("parseOpenAIStream", () => { } as any; const events: ChatStreamEvent[] = []; - await parseOpenAIStream(reader, (e) => events.push(e), controller.signal); + await expect(parseOpenAIStream(reader, (e) => events.push(e), controller.signal)).rejects.toThrow("Aborted"); expect(events).toHaveLength(0); }); @@ -431,6 +459,22 @@ describe("parseOpenAIStream", () => { } }); + it("EOF 前从未见过 finish_reason 时不应当作成功完成——网络中断和正常结束无法区分(finding 7)", async () => { + // 只有内容 delta,没有任何 chunk 带 finish_reason,也没有 [DONE]:模拟网络中断 + const reader = createMockReader([ + 'data: {"choices":[{"delta":{"content":"部分"}}]}\n\n', + 'data: {"choices":[{"delta":{"content":"回答"}}]}\n\n', + ]); + + const events: ChatStreamEvent[] = []; + const controller = new AbortController(); + + await parseOpenAIStream(reader, (e) => events.push(e), controller.signal); + + expect(events).toHaveLength(3); + expect(events[2].type).toBe("error"); + }); + it("应解析单个 chunk 内的 ... 标签", async () => { const reader = createMockReader([ 'data: {"choices":[{"delta":{"content":"beforereasoningafter"}}]}\n\n', diff --git a/src/app/service/agent/core/providers/openai.ts b/src/app/service/agent/core/providers/openai.ts index 619c14a17..0e4d9b5d3 100644 --- a/src/app/service/agent/core/providers/openai.ts +++ b/src/app/service/agent/core/providers/openai.ts @@ -100,8 +100,9 @@ export function buildOpenAIRequest( stream_options: { include_usage: true }, }; - if (config.maxTokens) { - body.max_tokens = config.maxTokens; + // config.maxTokens > 0 而非直接 truthy 判断:负数在 JS 里是 truthy,会绕过校验原样发给 provider(见 finding 10) + if (typeof config.maxTokens === "number" && Number.isFinite(config.maxTokens) && config.maxTokens > 0) { + body.max_tokens = Math.floor(config.maxTokens); } // 添加工具定义 @@ -149,6 +150,11 @@ export function parseOpenAIStream( | undefined; // 标记是否已通过 [DONE] 信号发出了 done 事件,避免 .then() 再次发出 let doneSent = false; + // 标记是否观察到过 provider 的完成标记(finish_reason 非空)。 + // reader EOF 但没有 [DONE] 时,只有见过 finish_reason 才能确认是"提前不发 [DONE] 的 + // 非标准 provider";否则无法区分"网络中断"与"正常结束",不应把截断的部分内容当作 + // 完整答案持久化(见 finding 7)。 + let sawFinishReason = false; // 跨 chunk 追踪 ... 块状态(用于把思考混在 content 里的模型) let inThinkBlock = false; @@ -192,6 +198,7 @@ export function parseOpenAIStream( const choice = json.choices?.[0]; if (choice) { + if (choice.finish_reason != null) sawFinishReason = true; const delta = choice.delta; if (delta) { // 思考过程增量(reasoning_content 兼容 deepseek / openai o-series) @@ -306,13 +313,26 @@ export function parseOpenAIStream( doneSent = true; onEvent({ type: "error", message }); } - ).then(() => { - // 流正常结束但没收到 [DONE](某些 API 可能如此) - if (!signal.aborted && !doneSent) { - flushThinkCarry(); - onEvent({ type: "done", usage: lastUsage }); - } - }); + ) + .then(() => { + // 流正常结束但没收到 [DONE]。只有见过 finish_reason(某些 API 提前不发 [DONE] 但仍会 + // 标出完成原因)才能确认这是真正的完整回答;否则无法与网络中断区分,按未预期断连报错, + // 不能把可能截断的部分内容当作成功答案持久化(见 finding 7) + if (!signal.aborted && !doneSent) { + flushThinkCarry(); + if (sawFinishReason) { + onEvent({ type: "done", usage: lastUsage }); + } else { + onEvent({ type: "error", message: "Stream ended unexpectedly without a finish reason", usage: lastUsage }); + } + } + }) + .catch((error) => { + // readSSEStream 只在 abort 时才 reject(见 content_utils.ts);把已知的部分 usage + // (如某些 provider 每个 chunk 都带 usage)带在 abort 错误上,避免取消时把这部分 + // 已经产生的花费从终态 usage 里丢掉(见 finding 6) + throw Object.assign(error instanceof Error ? error : new Error(String(error)), { usage: lastUsage }); + }); } // ---- LLMProvider 接口适配 ---- diff --git a/src/app/service/agent/core/session_tool_registry.test.ts b/src/app/service/agent/core/session_tool_registry.test.ts index f821086e0..c2889f0be 100644 --- a/src/app/service/agent/core/session_tool_registry.test.ts +++ b/src/app/service/agent/core/session_tool_registry.test.ts @@ -163,6 +163,23 @@ describe("SessionToolRegistry", () => { expect(parentSpy).not.toHaveBeenCalled(); }); + it("应将 signal 传递给 session 工具执行器", async () => { + const parent = new ToolRegistry(); + const session = new SessionToolRegistry(parent); + const executeSpy = vi.fn().mockResolvedValue("session_result"); + session.register("session", taskDef, { execute: executeSpy }); + const controller = new AbortController(); + + await session.execute( + [{ id: "t1", name: "create_task", arguments: "{}" }], + undefined, + undefined, + controller.signal + ); + + expect(executeSpy).toHaveBeenCalledWith({}, controller.signal); + }); + it("session 无该工具时回退到 parent 工具", async () => { const parent = new ToolRegistry(); parent.registerBuiltin( diff --git a/src/app/service/agent/core/session_tool_registry.ts b/src/app/service/agent/core/session_tool_registry.ts index 1cea9f7d3..931b73c9f 100644 --- a/src/app/service/agent/core/session_tool_registry.ts +++ b/src/app/service/agent/core/session_tool_registry.ts @@ -85,7 +85,8 @@ export class SessionToolRegistry implements ToolExecutorLike { async execute( toolCalls: ToolCall[], scriptCallback?: ScriptToolCallback | null, - excludeTools?: Set + excludeTools?: Set, + signal?: AbortSignal ): Promise { // 构建合并 Map:parent 在下、session 在上(session 覆盖 parent 同名工具) const merged = new Map(); @@ -95,6 +96,6 @@ export class SessionToolRegistry implements ToolExecutorLike { for (const [name, entry] of this.sessionTools) { merged.set(name, entry); } - return this.parent.executeTools(merged, toolCalls, scriptCallback, excludeTools); + return this.parent.executeTools(merged, toolCalls, scriptCallback, excludeTools, signal); } } diff --git a/src/app/service/agent/core/skill_script_executor.test.ts b/src/app/service/agent/core/skill_script_executor.test.ts index a6d90d710..04e95176f 100644 --- a/src/app/service/agent/core/skill_script_executor.test.ts +++ b/src/app/service/agent/core/skill_script_executor.test.ts @@ -507,7 +507,9 @@ describe("SkillScriptExecutor 超时处理", () => { let capturedUuid = ""; const sender = { sendMessage: vi.fn().mockImplementation((msg: any) => { - capturedUuid = msg.data.uuid; + if (msg.action === "offscreen/executeSkillScript") { + capturedUuid = msg.data.uuid; + } return new Promise(() => {}); }), } as any; @@ -566,4 +568,33 @@ describe("SkillScriptExecutor 超时处理", () => { await expect(execPromise).resolves.toBeDefined(); }); + + it("signal 中止时应停止脚本并返回 Aborted", async () => { + let capturedUuid = ""; + const sender = { + sendMessage: vi.fn().mockImplementation((msg: any) => { + if (msg.action === "offscreen/executeSkillScript") { + capturedUuid = msg.data.uuid; + return new Promise(() => {}); + } + if (msg.action === "offscreen/script/stopScript") { + return Promise.resolve({ data: undefined }); + } + return Promise.resolve({ data: "mock_result" }); + }), + } as any; + + const record = createRecord([], { name: "abort_tool" }); + const executor = new SkillScriptExecutor(record, sender); + const controller = new AbortController(); + + const execPromise = executor.execute({}, controller.signal); + controller.abort(); + + await expect(execPromise).rejects.toThrow("Aborted"); + expect(sender.sendMessage.mock.calls.some((call: any[]) => call[0].action === "offscreen/script/stopScript")).toBe( + true + ); + expect(getSkillScriptNameByUuid(capturedUuid)).toBe(""); + }); }); diff --git a/src/app/service/agent/core/skill_script_executor.ts b/src/app/service/agent/core/skill_script_executor.ts index ff8f332de..f9ee7ebb4 100644 --- a/src/app/service/agent/core/skill_script_executor.ts +++ b/src/app/service/agent/core/skill_script_executor.ts @@ -2,9 +2,9 @@ import type { MessageSend } from "@Packages/message/types"; import type { SkillScriptRecord, JsonValue } from "./types"; import type { ToolExecutor } from "./tool_registry"; import { getSkillScriptBody } from "@App/pkg/utils/skill_script"; -import { executeSkillScript } from "@App/app/service/offscreen/client"; +import { executeSkillScript, stopScript } from "@App/app/service/offscreen/client"; import { uuidv4 } from "@App/pkg/utils/uuid"; -import { withTimeout } from "@App/pkg/utils/with_timeout"; +import { createAbortError, throwIfAborted } from "./abort_utils"; // Skill Script UUID 前缀,用于在 GM API 请求中识别 Skill Script export const SKILL_SCRIPT_UUID_PREFIX = "skillscript-"; @@ -40,7 +40,9 @@ export class SkillScriptExecutor implements ToolExecutor { private configValues?: Record ) {} - async execute(args: Record): Promise { + async execute(args: Record, signal?: AbortSignal): Promise { + throwIfAborted(signal); + // 根据 @param 定义做基本的类型转换 const typedArgs: Record = {}; for (const param of this.record.params) { @@ -58,10 +60,6 @@ export class SkillScriptExecutor implements ToolExecutor { } } - // 在 service worker 端生成 UUID 并注册映射 - const uuid = SKILL_SCRIPT_UUID_PREFIX + uuidv4(); - skillScriptUuidMap.set(uuid, { name: this.record.name, grants: this.record.grants }); - // 加载 @require 资源内容 let requires: Array<{ url: string; content: string }> | undefined; if (this.record.requires?.length && this.requireLoader) { @@ -80,22 +78,66 @@ export class SkillScriptExecutor implements ToolExecutor { const code = getSkillScriptBody(this.record.code); const timeoutMs = this.record.timeout ? this.record.timeout * 1000 : SKILL_SCRIPT_DEFAULT_TIMEOUT_MS; const timeoutSec = timeoutMs / 1000; - try { - const execPromise = executeSkillScript(this.sender, { - uuid, - code, - args: typedArgs, - grants: this.record.grants, - name: this.record.name, - requires, - configValues: this.configValues, + throwIfAborted(signal); + + // 在 service worker 端生成 UUID 并注册映射 + const uuid = SKILL_SCRIPT_UUID_PREFIX + uuidv4(); + skillScriptUuidMap.set(uuid, { name: this.record.name, grants: this.record.grants }); + + const execPromise = executeSkillScript(this.sender, { + uuid, + code, + args: typedArgs, + grants: this.record.grants, + name: this.record.name, + requires, + configValues: this.configValues, + }); + + let timeoutId: ReturnType | undefined; + let abortCleanup = () => {}; + let stopped = false; + const stopExecution = async () => { + if (stopped) return; + stopped = true; + try { + await stopScript(this.sender, uuid); + } catch { + // 停止脚本失败不覆盖原始中止/超时错误 + } + }; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + void stopExecution(); + reject( + Object.assign(new Error(`SkillScript "${this.record.name}" timed out after ${timeoutSec}s`), { + errorCode: "tool_timeout", + }) + ); + }, timeoutMs); + }); + const abortPromise = + signal && + new Promise((_, reject) => { + const onAbort = () => { + void stopExecution(); + reject(createAbortError()); + }; + abortCleanup = () => signal.removeEventListener("abort", onAbort); + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) onAbort(); }); - return await withTimeout(execPromise, timeoutMs, () => - Object.assign(new Error(`SkillScript "${this.record.name}" timed out after ${timeoutSec}s`), { - errorCode: "tool_timeout", - }) - ); + + try { + const races: Promise[] = [execPromise, timeoutPromise]; + if (abortPromise) { + races.push(abortPromise); + } + const result = await Promise.race(races); + return result as JsonValue; } finally { + if (timeoutId) clearTimeout(timeoutId); + abortCleanup(); // 执行完毕后清理映射 skillScriptUuidMap.delete(uuid); } diff --git a/src/app/service/agent/core/task_scheduler.test.ts b/src/app/service/agent/core/task_scheduler.test.ts index a67c400ee..3d6ee9ada 100644 --- a/src/app/service/agent/core/task_scheduler.test.ts +++ b/src/app/service/agent/core/task_scheduler.test.ts @@ -212,6 +212,24 @@ describe("AgentTaskScheduler", () => { expect(updatedTask!.lastRunError).toBe("LLM 调用失败"); }); + it("执行失败时保留已累计的 usage", async () => { + internalExecutor.mockRejectedValue( + Object.assign(new Error("超过最大迭代次数"), { + usage: { inputTokens: 120, outputTokens: 40 }, + conversationId: "conv-failed", + }) + ); + + const task = makeTask({ id: "error-usage-1", nextruntime: Date.now() - 1000 }); + await repo.saveTask(task); + await scheduler.executeTask(task); + + const runs = await runRepo.listRuns("error-usage-1"); + expect(runs[0].status).toBe("error"); + expect(runs[0].usage).toEqual({ inputTokens: 120, outputTokens: 40 }); + expect(runs[0].conversationId).toBe("conv-failed"); + }); + it("执行完成后更新 nextruntime", async () => { const task = makeTask({ id: "next-1", nextruntime: Date.now() - 1000 }); await repo.saveTask(task); diff --git a/src/app/service/agent/core/task_scheduler.ts b/src/app/service/agent/core/task_scheduler.ts index 280354ac5..c110d4089 100644 --- a/src/app/service/agent/core/task_scheduler.ts +++ b/src/app/service/agent/core/task_scheduler.ts @@ -83,6 +83,8 @@ export class AgentTaskScheduler { } catch (e: any) { run.status = "error"; run.error = e.message || "Unknown error"; + if (e.usage) run.usage = e.usage; + if (e.conversationId) run.conversationId = e.conversationId; run.endtime = Date.now(); task.lastRunStatus = "error"; diff --git a/src/app/service/agent/core/tool_registry.test.ts b/src/app/service/agent/core/tool_registry.test.ts index d9d5992c9..d70cd215c 100644 --- a/src/app/service/agent/core/tool_registry.test.ts +++ b/src/app/service/agent/core/tool_registry.test.ts @@ -5,7 +5,7 @@ import type { ToolCall, ToolDefinition, ToolResultWithAttachments } from "./type import type { AgentChatRepo } from "@App/app/repo/agent_chat"; // 创建一个简单的 mock executor -function createExecutor(fn: (args: Record) => Promise): ToolExecutor { +function createExecutor(fn: (args: Record, signal?: AbortSignal) => Promise): ToolExecutor { return { execute: fn }; } @@ -105,6 +105,30 @@ describe("ToolRegistry", () => { expect(results[0].result).toBe('{"temp":25,"unit":"C"}'); }); + it("内置工具返回 undefined 或不可序列化值时应返回已定义的字符串", async () => { + const registry = new ToolRegistry(); + const cyclic: { self?: unknown } = {}; + cyclic.self = cyclic; + registry.registerBuiltin( + weatherDef, + createExecutor(async () => undefined) + ); + registry.registerBuiltin( + calcDef, + createExecutor(async () => cyclic) + ); + + const results = await registry.execute([ + { id: "tc_1", name: "get_weather", arguments: "{}" }, + { id: "tc_2", name: "calc", arguments: "{}" }, + ]); + + expect(results).toEqual([ + { id: "tc_1", result: "null" }, + { id: "tc_2", result: "null" }, + ]); + }); + it("空 arguments 时应传入空对象", async () => { const registry = new ToolRegistry(); const executeSpy = vi.fn().mockResolvedValue("ok"); @@ -112,7 +136,7 @@ describe("ToolRegistry", () => { await registry.execute([{ id: "tc_1", name: "get_weather", arguments: "" }]); - expect(executeSpy).toHaveBeenCalledWith({}); + expect(executeSpy).toHaveBeenCalledWith({}, undefined); }); it("内置工具抛出异常时应返回错误信息", async () => { @@ -162,7 +186,7 @@ describe("ToolRegistry", () => { const results = await registry.execute([{ id: "tc_1", name: "unknown_tool", arguments: "{}" }], scriptCallback); - expect(scriptCallback).toHaveBeenCalledWith([{ id: "tc_1", name: "unknown_tool", arguments: "{}" }]); + expect(scriptCallback).toHaveBeenCalledWith([{ id: "tc_1", name: "unknown_tool", arguments: "{}" }], undefined); expect(results[0].result).toBe("script result"); }); @@ -205,7 +229,7 @@ describe("ToolRegistry", () => { // 脚本工具结果 expect(results.find((r) => r.id === "tc_2")?.result).toBe("script_result"); // scriptCallback 只收到脚本工具 - expect(scriptCallback).toHaveBeenCalledWith([toolCalls[1]]); + expect(scriptCallback).toHaveBeenCalledWith([toolCalls[1]], undefined); }); it("空 toolCalls 数组时应返回空结果", async () => { @@ -214,6 +238,40 @@ describe("ToolRegistry", () => { expect(results).toHaveLength(0); }); + it("应将 signal 传递给内置工具执行器", async () => { + const registry = new ToolRegistry(); + const executeSpy = vi.fn().mockResolvedValue("ok"); + registry.registerBuiltin(weatherDef, { execute: executeSpy }); + const controller = new AbortController(); + + await registry.execute( + [{ id: "tc_1", name: "get_weather", arguments: "{}" }], + undefined, + undefined, + controller.signal + ); + + expect(executeSpy).toHaveBeenCalledWith({}, controller.signal); + }); + + it("取消时不等待不响应 signal 的内置工具", async () => { + const registry = new ToolRegistry(); + const executeSpy = vi.fn().mockReturnValue(new Promise(() => {})); + registry.registerBuiltin(weatherDef, { execute: executeSpy }); + const controller = new AbortController(); + + const resultPromise = registry.execute( + [{ id: "tc_1", name: "get_weather", arguments: "{}" }], + undefined, + undefined, + controller.signal + ); + controller.abort(); + + await expect(resultPromise).resolves.toMatchObject([{ id: "tc_1", error: true }]); + expect(executeSpy).toHaveBeenCalledWith({}, controller.signal); + }); + it("JSON 解析失败时应返回错误", async () => { const registry = new ToolRegistry(); const executor = createExecutor(async () => "ok"); @@ -386,6 +444,39 @@ describe("ToolRegistry", () => { expect(mockRepo.saveAttachment).toHaveBeenCalledTimes(2); }); + it("保存附件期间 signal abort:应停止继续写入并把该 toolCall 标为 error(finding 4)", async () => { + const registry = new ToolRegistry(); + const mockRepo = createMockChatRepo(); + const controller = new AbortController(); + // 第一个附件保存"成功"的同时触发 abort,模拟 Stop 恰好落在多附件保存期间 + (mockRepo.saveAttachment as any).mockImplementationOnce(async () => { + controller.abort(); + return 1024; + }); + registry.setChatRepo(mockRepo); + + const structuredResult: ToolResultWithAttachments = { + content: "Files generated.", + attachments: [ + { type: "image", name: "img1.png", mimeType: "image/png", data: "data:image/png;base64,abc" }, + { type: "file", name: "report.xlsx", mimeType: "application/octet-stream", data: "base64data" }, + ], + }; + const executor = createExecutor(async () => structuredResult); + registry.registerBuiltin(weatherDef, executor); + + const results = await registry.execute( + [{ id: "tc_1", name: "get_weather", arguments: "{}" }], + undefined, + undefined, + controller.signal + ); + + // 只应写入第一个附件,第二个在 abort 后不再保存 + expect(mockRepo.saveAttachment).toHaveBeenCalledTimes(1); + expect(results[0].error).toBe(true); + }); + it("内置工具返回 Blob 附件时应正确保存", async () => { const registry = new ToolRegistry(); const mockRepo = createMockChatRepo(); diff --git a/src/app/service/agent/core/tool_registry.ts b/src/app/service/agent/core/tool_registry.ts index 72ff81b15..2a24e25f9 100644 --- a/src/app/service/agent/core/tool_registry.ts +++ b/src/app/service/agent/core/tool_registry.ts @@ -2,10 +2,11 @@ import type { Attachment, SubAgentDetails, ToolCall, ToolDefinition, ToolResultW import type { AgentChatRepo } from "@App/app/repo/agent_chat"; import { uuidv4 } from "@App/pkg/utils/uuid"; import { getExtFromMime } from "./content_utils"; +import { raceWithAbort, throwIfAborted } from "./abort_utils"; // 工具执行器接口 export interface ToolExecutor { - execute(args: Record): Promise; + execute(args: Record, signal?: AbortSignal): Promise; } // 工具来源分类 @@ -24,12 +25,16 @@ export interface ToolEntry { } // 脚本工具回调类型:将 tool calls 发送到 Sandbox 执行 -export type ScriptToolCallback = (toolCalls: ToolCall[]) => Promise>; +export type ScriptToolCallback = ( + toolCalls: ToolCall[], + signal?: AbortSignal +) => Promise>; // 工具执行结果(可能含附件和子代理详情) export type ToolExecuteResult = { id: string; result: string; + error?: boolean; attachments?: Attachment[]; subAgentDetails?: SubAgentDetails; }; @@ -41,7 +46,8 @@ export interface ToolExecutorLike { execute( toolCalls: ToolCall[], scriptCallback?: ScriptToolCallback | null, - excludeTools?: Set + excludeTools?: Set, + signal?: AbortSignal ): Promise; } @@ -52,6 +58,15 @@ function extractErrorMessage(e: unknown): string { return String(e) || "Tool execution failed"; } +function normalizeToolResult(value: unknown): string { + if (typeof value === "string") return value; + try { + return JSON.stringify(value) ?? "null"; + } catch { + return "null"; + } +} + // 判断返回值是否是带附件的结构化结果 function isToolResultWithAttachments(value: unknown): value is ToolResultWithAttachments { if (typeof value !== "object" || value === null) return false; @@ -171,9 +186,10 @@ export class ToolRegistry implements ToolExecutorLike { async execute( toolCalls: ToolCall[], scriptCallback?: ScriptToolCallback | null, - excludeTools?: Set + excludeTools?: Set, + signal?: AbortSignal ): Promise { - return this.executeTools(this.tools, toolCalls, scriptCallback, excludeTools); + return this.executeTools(this.tools, toolCalls, scriptCallback, excludeTools, signal); } // 执行工具调用(接收外部 tools Map),供 SessionToolRegistry 复用附件保存等共享逻辑 @@ -182,7 +198,8 @@ export class ToolRegistry implements ToolExecutorLike { tools: ReadonlyMap, toolCalls: ToolCall[], scriptCallback?: ScriptToolCallback | null, - excludeTools?: Set + excludeTools?: Set, + signal?: AbortSignal ): Promise { const results: ToolExecuteResult[] = []; const builtinCalls: ToolCall[] = []; @@ -194,6 +211,7 @@ export class ToolRegistry implements ToolExecutorLike { results.push({ id: tc.id, result: JSON.stringify({ error: `Tool "${tc.name}" is not available in this context` }), + error: true, }); continue; } @@ -209,46 +227,67 @@ export class ToolRegistry implements ToolExecutorLike { builtinCalls.map(async (tc): Promise => { const tool = tools.get(tc.name)!; try { + throwIfAborted(signal); let args: Record = {}; if (tc.arguments) { args = JSON.parse(tc.arguments); } - const rawResult = await tool.executor.execute(args); + const rawResult = await raceWithAbort(tool.executor.execute(args, signal), signal); // 检查是否带附件或子代理详情 if (isToolResultWithAttachments(rawResult)) { - const attachments = await this.saveAttachments(rawResult.attachments); + const attachments = await this.saveAttachments(rawResult.attachments, signal); return { id: tc.id, result: rawResult.content, attachments }; } else if (isToolResultWithSubAgent(rawResult)) { return { id: tc.id, result: rawResult.content, subAgentDetails: rawResult.subAgentDetails }; } else { - return { id: tc.id, result: typeof rawResult === "string" ? rawResult : JSON.stringify(rawResult) }; + return { id: tc.id, result: normalizeToolResult(rawResult) }; } } catch (e: any) { console.error(`[ToolRegistry] tool "${tc.name}" execution failed:`, e); - return { id: tc.id, result: JSON.stringify({ error: extractErrorMessage(e) }) }; + return { + id: tc.id, + result: JSON.stringify({ error: extractErrorMessage(e) }), + error: true, + subAgentDetails: e.subAgentDetails, + }; } }) ); results.push(...builtinResults); + if (signal?.aborted) { + return results; + } + // 执行脚本工具 if (scriptCalls.length > 0) { if (scriptCallback) { - const scriptResults = await scriptCallback(scriptCalls); + const scriptResults = await raceWithAbort(scriptCallback(scriptCalls, signal), signal); // 脚本工具也可能返回带附件的结构化结果 for (const sr of scriptResults) { try { const parsed = JSON.parse(sr.result); if (isToolResultWithAttachments(parsed)) { - const attachments = await this.saveAttachments(parsed.attachments); - results.push({ id: sr.id, result: parsed.content, attachments }); + const attachments = await this.saveAttachments(parsed.attachments, signal); + results.push({ id: sr.id, result: parsed.content, attachments, error: sr.error }); + continue; + } + } catch (e: any) { + // signal 已 abort 时是附件写入被中止(见 saveAttachments 的 throwIfAborted), + // 不能落回"按原始字符串处理"分支——那会让脚本工具的原始成功结果继续被当作 + // 已完成上报,掩盖掉附件其实没写完的事实(见 finding 4) + if (signal?.aborted) { + results.push({ + id: sr.id, + result: JSON.stringify({ error: extractErrorMessage(e) }), + error: true, + }); continue; } - } catch { // 不是 JSON 或不是结构化结果,按原始字符串处理 } - results.push({ id: sr.id, result: sr.result }); + results.push({ id: sr.id, result: sr.result, error: sr.error }); } } else { // 没有脚本回调,返回错误并列出可用工具名,引导 LLM 自我纠正 @@ -262,6 +301,7 @@ export class ToolRegistry implements ToolExecutorLike { result: JSON.stringify({ error: `Tool "${tc.name}" not found. Available tools: [${availableNames.join(", ")}].${hint}`, }), + error: true, }); } } @@ -270,12 +310,18 @@ export class ToolRegistry implements ToolExecutorLike { return results; } - // 保存附件数据到 OPFS,返回 Attachment 元数据 - private async saveAttachments(attachmentDataList: ToolResultWithAttachments["attachments"]): Promise { + // 保存附件数据到 OPFS,返回 Attachment 元数据。 + // 传入 signal 时在每个附件写入前检查,abort 时中止剩余写入并抛错——调用方的 catch 块会把这 + // 转成该 toolCall 的 error 结果,避免 Stop 之后仍继续写多个文件(见 finding 4)。 + private async saveAttachments( + attachmentDataList: ToolResultWithAttachments["attachments"], + signal?: AbortSignal + ): Promise { if (!this.chatRepo || attachmentDataList.length === 0) return []; const attachments: Attachment[] = []; for (const ad of attachmentDataList) { + throwIfAborted(signal); if (!ad.data) { // 无 data 的附件是已保存的引用(如 skill script 返回的 imageBlock),直接透传元数据 if ("attachmentId" in ad && (ad as any).attachmentId) { diff --git a/src/app/service/agent/core/tools/ask_user.test.ts b/src/app/service/agent/core/tools/ask_user.test.ts index bb0c1fd70..62bb733d9 100644 --- a/src/app/service/agent/core/tools/ask_user.test.ts +++ b/src/app/service/agent/core/tools/ask_user.test.ts @@ -27,6 +27,7 @@ describe("ask_user", () => { const result = await resultPromise; expect(JSON.parse(result as string)).toEqual({ answer: "Blue" }); expect(resolvers.size).toBe(0); + expect(events.filter((event) => event.type === "ask_user_resolved")).toHaveLength(1); }); it("should throw if question is missing", async () => { @@ -51,10 +52,25 @@ describe("ask_user", () => { const result = JSON.parse((await resultPromise) as string); expect(result).toEqual({ answer: null, reason: "timeout" }); expect(resolvers.size).toBe(0); + expect(sendEvent).toHaveBeenLastCalledWith(expect.objectContaining({ type: "ask_user_expired" })); vi.useRealTimers(); }); + it("should settle and emit expiration when aborted", async () => { + const controller = new AbortController(); + const sendEvent = vi.fn(); + const resolvers = new Map void>(); + const { executor } = createAskUserTool(sendEvent, resolvers, controller.signal); + + const resultPromise = executor.execute({ question: "Waiting..." }); + controller.abort(); + + expect(JSON.parse((await resultPromise) as string)).toEqual({ answer: null, reason: "aborted" }); + expect(resolvers.size).toBe(0); + expect(sendEvent).toHaveBeenLastCalledWith(expect.objectContaining({ type: "ask_user_expired" })); + }); + it("should generate unique ask IDs", async () => { const events: ChatStreamEvent[] = []; const sendEvent = (event: ChatStreamEvent) => events.push(event); diff --git a/src/app/service/agent/core/tools/ask_user.ts b/src/app/service/agent/core/tools/ask_user.ts index a473dd48b..8f5000d57 100644 --- a/src/app/service/agent/core/tools/ask_user.ts +++ b/src/app/service/agent/core/tools/ask_user.ts @@ -31,7 +31,8 @@ const ASK_USER_TIMEOUT_MS = 5 * 60 * 1000; export function createAskUserTool( sendEvent: (event: ChatStreamEvent) => void, - resolvers: Map void> + resolvers: Map void>, + signal: AbortSignal = new AbortController().signal ): { definition: ToolDefinition; executor: ToolExecutor } { let askCounter = 0; @@ -44,21 +45,34 @@ export function createAskUserTool( const askId = `ask_${Date.now()}_${++askCounter}`; + if (signal.aborted) return JSON.stringify({ answer: null, reason: "aborted" }); + // 通知 UI 显示提问 sendEvent({ type: "ask_user", id: askId, question, options, multiple }); // 等待用户回复 return new Promise((resolve) => { - const timer = setTimeout(() => { + let settled = false; + const finish = (result: string, event: ChatStreamEvent) => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal.removeEventListener("abort", onAbort); resolvers.delete(askId); - resolve(JSON.stringify({ answer: null, reason: "timeout" })); + sendEvent(event); + resolve(result); + }; + const onAbort = () => { + finish(JSON.stringify({ answer: null, reason: "aborted" }), { type: "ask_user_expired", id: askId }); + }; + const timer = setTimeout(() => { + finish(JSON.stringify({ answer: null, reason: "timeout" }), { type: "ask_user_expired", id: askId }); }, ASK_USER_TIMEOUT_MS); - + signal.addEventListener("abort", onAbort, { once: true }); resolvers.set(askId, (answer: string) => { - clearTimeout(timer); - resolvers.delete(askId); - resolve(JSON.stringify({ answer })); + finish(JSON.stringify({ answer }), { type: "ask_user_resolved", id: askId }); }); + if (signal.aborted) onAbort(); }); }, }; diff --git a/src/app/service/agent/core/tools/execute_script.test.ts b/src/app/service/agent/core/tools/execute_script.test.ts index 49a4fcd75..d4ce1db86 100644 --- a/src/app/service/agent/core/tools/execute_script.test.ts +++ b/src/app/service/agent/core/tools/execute_script.test.ts @@ -80,7 +80,7 @@ describe("execute_script 工具", () => { expect(parsed).toEqual({ result: { sum: 42 }, target: "sandbox" }); expect(parsed).not.toHaveProperty("tab_id"); - expect(mockExecuteInSandbox).toHaveBeenCalledWith("return 1+2"); + expect(mockExecuteInSandbox).toHaveBeenCalledWith("return 1+2", expect.any(AbortSignal)); }); it.concurrent("返回值为 undefined 时应转为 null", async () => { @@ -102,18 +102,102 @@ describe("execute_script 工具", () => { const { executor } = createExecuteScriptTool(deps); await expect(executor.execute({ code: "while(true){}", target: "page" })).rejects.toThrow( - "execute_script timed out after 0.05s" + "execute_script (target=page) timed out after 0.05s" ); }); it.concurrent("sandbox 模式超时应报错", async () => { - const mockExecuteInSandbox = vi.fn().mockReturnValue(new Promise(() => {})); + const onAbort = vi.fn(); + const mockExecuteInSandbox = vi.fn().mockImplementation((_code: string, signal?: AbortSignal) => { + signal?.addEventListener("abort", onAbort, { once: true }); + return new Promise(() => {}); + }); const deps = makeDeps({ executeInSandbox: mockExecuteInSandbox, timeoutMs: 50 }); const { executor } = createExecuteScriptTool(deps); await expect(executor.execute({ code: "while(true){}", target: "sandbox" })).rejects.toThrow( "execute_script timed out after 0.05s" ); + expect(onAbort).toHaveBeenCalledOnce(); + }); + + it.concurrent("signal 已中止时应立即中断并且不执行脚本", async () => { + const mockExecuteInSandbox = vi.fn().mockReturnValue(new Promise(() => {})); + const deps = makeDeps({ executeInSandbox: mockExecuteInSandbox }); + const { executor } = createExecuteScriptTool(deps); + const controller = new AbortController(); + controller.abort(); + + await expect(executor.execute({ code: "return 1", target: "sandbox" }, controller.signal)).rejects.toThrow( + "Aborted" + ); + expect(mockExecuteInSandbox).not.toHaveBeenCalled(); + }); + }); + + describe("超大返回值截断", () => { + it.concurrent("原始结果刚好低于限制但最终 JSON 信封超限时仍应截断", async () => { + const nearLimit = "x".repeat(29_970); + const deps = makeDeps({ executeInSandbox: vi.fn().mockResolvedValue(nearLimit) }); + const { executor } = createExecuteScriptTool(deps); + + const result = (await executor.execute({ code: "return nearLimit", target: "sandbox" })) as string; + const parsed = JSON.parse(result); + + expect(result.length).toBeLessThanOrEqual(30_000); + expect(parsed.truncated).toBe(true); + }); + + it.concurrent("最终 JSON 信封包含大量引号和反斜杠时仍不超过限制", async () => { + const escaped = '"\\'.repeat(40_000); + const deps = makeDeps({ executeInSandbox: vi.fn().mockResolvedValue(escaped) }); + const { executor } = createExecuteScriptTool(deps); + + const result = (await executor.execute({ code: "return escaped", target: "sandbox" })) as string; + + expect(result.length).toBeLessThanOrEqual(30_000); + expect(JSON.parse(result).truncated).toBe(true); + }); + + it.concurrent("page 模式返回值过大时应截断并标注 truncated", async () => { + const bigString = "x".repeat(50_000); + const mockExecuteInPage = vi.fn().mockResolvedValue({ result: bigString, tabId: 1 }); + const deps = makeDeps({ executeInPage: mockExecuteInPage }); + const { executor } = createExecuteScriptTool(deps); + + const result = await executor.execute({ code: "return bigString", target: "page" }); + const parsed = JSON.parse(result as string); + + expect(parsed.truncated).toBe(true); + expect(typeof parsed.result).toBe("string"); + expect(parsed.result.length).toBeLessThan(bigString.length); + expect(parsed.result).toContain("truncated"); + expect(parsed.original_length).toBeGreaterThan(50_000); + }); + + it.concurrent("sandbox 模式返回值过大时应截断并标注 truncated", async () => { + const bigArray = Array.from({ length: 10_000 }, (_, i) => i); + const mockExecuteInSandbox = vi.fn().mockResolvedValue(bigArray); + const deps = makeDeps({ executeInSandbox: mockExecuteInSandbox }); + const { executor } = createExecuteScriptTool(deps); + + const result = await executor.execute({ code: "return bigArray", target: "sandbox" }); + const parsed = JSON.parse(result as string); + + expect(parsed.truncated).toBe(true); + expect(typeof parsed.result).toBe("string"); + }); + + it.concurrent("正常大小的返回值不应被截断或添加 truncated 字段", async () => { + const mockExecuteInPage = vi.fn().mockResolvedValue({ result: { count: 5 }, tabId: 1 }); + const deps = makeDeps({ executeInPage: mockExecuteInPage }); + const { executor } = createExecuteScriptTool(deps); + + const result = await executor.execute({ code: "return {count:5}", target: "page" }); + const parsed = JSON.parse(result as string); + + expect(parsed).toEqual({ result: { count: 5 }, target: "page", tab_id: 1 }); + expect(parsed.truncated).toBeUndefined(); }); }); diff --git a/src/app/service/agent/core/tools/execute_script.ts b/src/app/service/agent/core/tools/execute_script.ts index 8091ef9f0..ebc99c640 100644 --- a/src/app/service/agent/core/tools/execute_script.ts +++ b/src/app/service/agent/core/tools/execute_script.ts @@ -1,6 +1,7 @@ import type { ToolDefinition } from "@App/app/service/agent/core/types"; import type { ToolExecutor } from "@App/app/service/agent/core/tool_registry"; import { withTimeout } from "@App/pkg/utils/with_timeout"; +import { createAbortError, throwIfAborted } from "../abort_utils"; import { requireString } from "./param_utils"; export const EXECUTE_SCRIPT_DEFINITION: ToolDefinition = { @@ -8,7 +9,10 @@ export const EXECUTE_SCRIPT_DEFINITION: ToolDefinition = { description: "Execute JavaScript code. " + "target='page': run in a browser tab (MAIN world) with full DOM access, shares page's window/globals — can access page JS variables and call page functions. Cannot access extension blob URLs. " + - "target='sandbox': isolated computation environment, no DOM. " + + "chrome.scripting.executeScript has no cancellation API: on timeout/stop this tool stops WAITING and returns an error, " + + "but the injected page code keeps running to completion in the tab (it is not actually terminated). " + + "Avoid long-running or blocking code with target='page'. " + + "target='sandbox': isolated computation environment, no DOM, and IS genuinely cancelled on timeout/stop. " + "Use `return` to return a value. Timeout: 30 seconds.", parameters: { type: "object", @@ -30,9 +34,89 @@ export const EXECUTE_SCRIPT_DEFINITION: ToolDefinition = { const EXECUTE_SCRIPT_TIMEOUT_MS = 30_000; +// 返回值过大时(如 DOM dump、模块映射)截断,避免其在后续每轮 tool loop 中被完整重复计费 +const MAX_RESULT_CHARS = 30_000; + +function executeSandboxWithTimeout( + execute: (signal: AbortSignal) => Promise, + parentSignal: AbortSignal | undefined, + timeoutMs: number +): Promise { + const controller = new AbortController(); + const onParentAbort = () => controller.abort(); + parentSignal?.addEventListener("abort", onParentAbort, { once: true }); + if (parentSignal?.aborted) controller.abort(); + + return new Promise((resolve, reject) => { + let settled = false; + let timedOut = false; + const cleanup = () => { + clearTimeout(timer); + controller.signal.removeEventListener("abort", onAbort); + parentSignal?.removeEventListener("abort", onParentAbort); + }; + const finish = (callback: () => void) => { + if (settled) return; + settled = true; + cleanup(); + callback(); + }; + const timeoutError = () => new Error(`execute_script timed out after ${timeoutMs / 1000}s`); + const onAbort = () => finish(() => reject(timedOut ? timeoutError() : createAbortError())); + + controller.signal.addEventListener("abort", onAbort, { once: true }); + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs); + if (controller.signal.aborted) { + onAbort(); + return; + } + + let execution: Promise; + try { + execution = execute(controller.signal); + } catch (error) { + finish(() => reject(error)); + return; + } + execution.then( + (result) => finish(() => resolve(result)), + (error) => finish(() => reject(error)) + ); + }); +} + +/** 将 result 序列化,超过阈值时截断为首尾各一部分并标注 truncated */ +function buildResultPayload(result: unknown, extra: Record): string { + const rawResult = result ?? null; + const resultStr = JSON.stringify(rawResult); + const normalPayload = JSON.stringify({ result: rawResult, ...extra }); + if (normalPayload.length <= MAX_RESULT_CHARS) return normalPayload; + const makePayload = (keptLength: number) => { + const headLength = Math.ceil(keptLength / 2); + const tailLength = keptLength - headLength; + const omitted = resultStr.length - keptLength; + const truncatedText = + resultStr.slice(0, headLength) + + `\n…[truncated ${omitted} chars — return a smaller value or write large data to OPFS via opfs_write]…\n` + + resultStr.slice(resultStr.length - tailLength); + return JSON.stringify({ result: truncatedText, ...extra, truncated: true, original_length: resultStr.length }); + }; + let low = 0; + let high = Math.min(MAX_RESULT_CHARS, resultStr.length); + while (low < high) { + const middle = Math.ceil((low + high) / 2); + if (makePayload(middle).length <= MAX_RESULT_CHARS) low = middle; + else high = middle - 1; + } + return makePayload(low); +} + export type ExecuteScriptDeps = { executeInPage: (code: string, options?: { tabId?: number }) => Promise<{ result: unknown; tabId: number }>; - executeInSandbox: (code: string) => Promise; + executeInSandbox: (code: string, signal?: AbortSignal) => Promise; timeoutMs?: number; // 可选超时(ms),默认 30s,测试用 }; @@ -43,7 +127,8 @@ export function createExecuteScriptTool(deps: ExecuteScriptDeps): { const timeoutMs = deps.timeoutMs ?? EXECUTE_SCRIPT_TIMEOUT_MS; const executor: ToolExecutor = { - execute: async (args: Record) => { + execute: async (args: Record, signal?: AbortSignal) => { + throwIfAborted(signal); const code = requireString(args, "code"); const target = requireString(args, "target"); @@ -52,22 +137,30 @@ export function createExecuteScriptTool(deps: ExecuteScriptDeps): { } if (target === "page") { + // chrome.scripting.executeScript 无法被真正中止:withTimeout 只能让调用方停止等待, + // 注入到页面的代码仍会在 tab 内继续跑到自然结束。错误信息必须说明"调用方停止等待" + // 与"页面脚本已停止"是两回事,避免误导上层以为页面副作用已经终止。 const tabId = args.tab_id as number | undefined; const { result, tabId: actualTabId } = await withTimeout( deps.executeInPage(code, { tabId }), timeoutMs, - () => new Error(`execute_script timed out after ${timeoutMs / 1000}s`) + () => + new Error( + `execute_script (target=page) timed out after ${timeoutMs / 1000}s waiting for a response. ` + + `The page code cannot be forcibly terminated and may still be running in the tab.` + ), + signal ); - return JSON.stringify({ result: result ?? null, target: "page", tab_id: actualTabId }); + return buildResultPayload(result, { target: "page", tab_id: actualTabId }); } // sandbox - const result = await withTimeout( - deps.executeInSandbox(code), - timeoutMs, - () => new Error(`execute_script timed out after ${timeoutMs / 1000}s`) + const result = await executeSandboxWithTimeout( + (executionSignal) => deps.executeInSandbox(code, executionSignal), + signal, + timeoutMs ); - return JSON.stringify({ result: result ?? null, target: "sandbox" }); + return buildResultPayload(result, { target: "sandbox" }); }, }; diff --git a/src/app/service/agent/core/tools/opfs_tools.test.ts b/src/app/service/agent/core/tools/opfs_tools.test.ts index ce12bd9bb..77cab1d9a 100644 --- a/src/app/service/agent/core/tools/opfs_tools.test.ts +++ b/src/app/service/agent/core/tools/opfs_tools.test.ts @@ -270,6 +270,18 @@ describe("opfs_tools", () => { '".." is not allowed' ); }); + + it("中止时不应写入文件", async () => { + const write = getTool("opfs_write"); + const read = getTool("opfs_read"); + const controller = new AbortController(); + controller.abort(); + + await expect( + write.executor.execute({ path: "cancelled.txt", content: "bad" }, controller.signal) + ).rejects.toThrow("Aborted"); + await expect(read.executor.execute({ path: "cancelled.txt" })).rejects.toThrow(); + }); }); describe("opfs_read 文本读取", () => { diff --git a/src/app/service/agent/core/tools/opfs_tools.ts b/src/app/service/agent/core/tools/opfs_tools.ts index f2810e8ba..e4cb3643c 100644 --- a/src/app/service/agent/core/tools/opfs_tools.ts +++ b/src/app/service/agent/core/tools/opfs_tools.ts @@ -8,6 +8,7 @@ import { writeWorkspaceFile, } from "@App/app/service/agent/core/opfs_helpers"; import { isText } from "@App/pkg/utils/istextorbinary"; +import { throwIfAborted } from "../abort_utils"; import { requireString } from "./param_utils"; // re-export sanitizePath 供外部使用 @@ -134,25 +135,30 @@ export function createOPFSTools(): { tools: Array<{ definition: ToolDefinition; executor: ToolExecutor }>; } { const writeExecutor: ToolExecutor = { - execute: async (args: Record) => { + execute: async (args: Record, signal?: AbortSignal) => { + throwIfAborted(signal); const path = requireString(args, "path"); - const result = await writeWorkspaceFile(path, args.content as string | Blob); + const result = await writeWorkspaceFile(path, args.content as string | Blob, signal); + throwIfAborted(signal); return JSON.stringify(result); }, }; const readExecutor: ToolExecutor = { - execute: async (args: Record) => { + execute: async (args: Record, signal?: AbortSignal) => { + throwIfAborted(signal); const safePath = sanitizePath(requireString(args, "path")); if (!safePath) throw new Error("path is required"); - const workspace = await getWorkspaceRoot(); + const workspace = await getWorkspaceRoot(false, signal); const { dirPath, fileName } = splitPath(safePath); - const dir = dirPath ? await getDirectory(workspace, dirPath) : workspace; + const dir = dirPath ? await getDirectory(workspace, dirPath, false, signal) : workspace; + throwIfAborted(signal); const fileHandle = await dir.getFileHandle(fileName); const file = await fileHandle.getFile(); const mimeType = guessMimeType(safePath); const arrayBuffer = await file.arrayBuffer(); + throwIfAborted(signal); // 确定返回模式:auto 通过内容字节检测文本/二进制 const mode = (args.mode as string) || "auto"; @@ -163,7 +169,9 @@ export function createOPFSTools(): { if (!createBlobUrlFn) { throw new Error("Blob URL creation not available (Offscreen not initialized)"); } + throwIfAborted(signal); const blobUrl = await createBlobUrlFn(arrayBuffer, mimeType); + throwIfAborted(signal); return JSON.stringify({ path: safePath, blobUrl, size: file.size, mimeType, type: "binary" }); } @@ -201,17 +209,20 @@ export function createOPFSTools(): { }; const listExecutor: ToolExecutor = { - execute: async (args: Record) => { + execute: async (args: Record, signal?: AbortSignal) => { + throwIfAborted(signal); const rawPath = (args.path as string) || ""; const safePath = sanitizePath(rawPath); - const workspace = await getWorkspaceRoot(true); - const dir = safePath ? await getDirectory(workspace, safePath) : workspace; + const workspace = await getWorkspaceRoot(true, signal); + const dir = safePath ? await getDirectory(workspace, safePath, false, signal) : workspace; const entries: Array<{ name: string; type: "file" | "directory"; size?: number }> = []; for await (const [name, handle] of dir as unknown as AsyncIterable<[string, FileSystemHandle]>) { + throwIfAborted(signal); if (handle.kind === "file") { const f = await (handle as FileSystemFileHandle).getFile(); + throwIfAborted(signal); entries.push({ name, type: "file", size: f.size }); } else { entries.push({ name, type: "directory" }); @@ -223,13 +234,15 @@ export function createOPFSTools(): { }; const deleteExecutor: ToolExecutor = { - execute: async (args: Record) => { + execute: async (args: Record, signal?: AbortSignal) => { + throwIfAborted(signal); const safePath = sanitizePath(requireString(args, "path")); if (!safePath) throw new Error("path is required"); - const workspace = await getWorkspaceRoot(); + const workspace = await getWorkspaceRoot(false, signal); const { dirPath, fileName } = splitPath(safePath); - const dir = dirPath ? await getDirectory(workspace, dirPath) : workspace; + const dir = dirPath ? await getDirectory(workspace, dirPath, false, signal) : workspace; + throwIfAborted(signal); await dir.removeEntry(fileName, { recursive: true }); return JSON.stringify({ success: true }); diff --git a/src/app/service/agent/core/tools/tab_tools.test.ts b/src/app/service/agent/core/tools/tab_tools.test.ts index 52b309cb1..83d2ba00e 100644 --- a/src/app/service/agent/core/tools/tab_tools.test.ts +++ b/src/app/service/agent/core/tools/tab_tools.test.ts @@ -150,7 +150,7 @@ describe("get_tab_content", () => { const raw = (await executor.execute({ tab_id: 1, prompt: "What is the price?" })) as string; const result = JSON.parse(raw); - expect(mockSummarize).toHaveBeenCalledWith(expect.any(String), "What is the price?"); + expect(mockSummarize).toHaveBeenCalledWith(expect.any(String), "What is the price?", undefined); expect(result.content).toBe("Summarized content"); expect(result.truncated).toBe(false); }); diff --git a/src/app/service/agent/core/tools/tab_tools.ts b/src/app/service/agent/core/tools/tab_tools.ts index b926a8a96..816650cd5 100644 --- a/src/app/service/agent/core/tools/tab_tools.ts +++ b/src/app/service/agent/core/tools/tab_tools.ts @@ -5,6 +5,7 @@ import { extractHtmlWithSelectors } from "@App/app/service/offscreen/client"; import { assertDomUrlAllowed } from "@App/app/service/agent/service_worker/dom_policy"; import { stripHtmlTags } from "./web_fetch"; import { requireNumber, requireString, optionalString, optionalNumber, optionalBoolean } from "./param_utils"; +import { raceWithAbort, throwIfAborted } from "../abort_utils"; // ---- Tool Definitions ---- @@ -98,12 +99,13 @@ const ACTIVATE_TAB_DEFINITION: ToolDefinition = { export function createTabTools(deps: { sender: MessageSend; - summarize: (content: string, prompt: string) => Promise; + summarize: (content: string, prompt: string, signal?: AbortSignal) => Promise; }): { tools: Array<{ definition: ToolDefinition; executor: ToolExecutor }> } { const { sender, summarize } = deps; const getTabContentExecutor: ToolExecutor = { - execute: async (args: Record) => { + execute: async (args: Record, signal?: AbortSignal) => { + throwIfAborted(signal); const tabId = requireNumber(args, "tab_id"); const prompt = args.prompt as string | undefined; const selector = optionalString(args, "selector"); @@ -156,11 +158,15 @@ export function createTabTools(deps: { } // 通过 Offscreen 提取 markdown(带 selector 标注) + throwIfAborted(signal); let content: string; try { - const extracted = await extractHtmlWithSelectors(sender, pageData.html); + const extracted = await raceWithAbort(extractHtmlWithSelectors(sender, pageData.html), signal); content = extracted && extracted.length > 20 ? extracted : pageData.html; - } catch { + } catch (error) { + if (error instanceof Error && error.message === "Aborted") { + throw error; + } // 降级:简单去标签 content = stripHtmlTags(pageData.html); } @@ -174,7 +180,7 @@ export function createTabTools(deps: { // LLM 摘要 if (prompt) { - content = await summarize(content, prompt); + content = await summarize(content, prompt, signal); truncated = false; // 摘要后不再截断 } @@ -190,7 +196,8 @@ export function createTabTools(deps: { }; const listTabsExecutor: ToolExecutor = { - execute: async (args: Record) => { + execute: async (args: Record, signal?: AbortSignal) => { + throwIfAborted(signal); const urlPattern = optionalString(args, "url_pattern"); const titlePattern = optionalString(args, "title_pattern"); const active = optionalBoolean(args, "active"); @@ -242,7 +249,8 @@ export function createTabTools(deps: { }; const openTabExecutor: ToolExecutor = { - execute: async (args: Record) => { + execute: async (args: Record, signal?: AbortSignal) => { + throwIfAborted(signal); const url = requireString(args, "url"); const tabId = optionalNumber(args, "tab_id"); @@ -253,19 +261,27 @@ export function createTabTools(deps: { await chrome.tabs.update(tabId, { url }); if (waitUntilLoaded) { - await new Promise((resolve) => { + await new Promise((resolve, reject) => { const timerId = setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); resolve(); }, 30_000); + const onAbort = () => { + clearTimeout(timerId); + chrome.tabs.onUpdated.removeListener(listener); + reject(new Error("Aborted")); + }; const listener = (updatedTabId: number, changeInfo: { status?: string }) => { if (updatedTabId === tabId && changeInfo.status === "complete") { clearTimeout(timerId); + signal?.removeEventListener("abort", onAbort); chrome.tabs.onUpdated.removeListener(listener); resolve(); } }; chrome.tabs.onUpdated.addListener(listener); + signal?.addEventListener("abort", onAbort, { once: true }); + if (signal?.aborted) onAbort(); }); } @@ -297,7 +313,8 @@ export function createTabTools(deps: { }; const closeTabExecutor: ToolExecutor = { - execute: async (args: Record) => { + execute: async (args: Record, signal?: AbortSignal) => { + throwIfAborted(signal); const tabId = requireNumber(args, "tab_id"); await chrome.tabs.remove(tabId); return JSON.stringify({ success: true, tab_id: tabId }); @@ -305,7 +322,8 @@ export function createTabTools(deps: { }; const activateTabExecutor: ToolExecutor = { - execute: async (args: Record) => { + execute: async (args: Record, signal?: AbortSignal) => { + throwIfAborted(signal); const tabId = requireNumber(args, "tab_id"); const tab = await chrome.tabs.update(tabId, { active: true }); if (!tab) throw new Error(`Tab ${tabId} not found`); diff --git a/src/app/service/agent/core/tools/task_tools.test.ts b/src/app/service/agent/core/tools/task_tools.test.ts index ac18fa40b..f5df63210 100644 --- a/src/app/service/agent/core/tools/task_tools.test.ts +++ b/src/app/service/agent/core/tools/task_tools.test.ts @@ -22,6 +22,20 @@ describe("task_tools", () => { expect(result2).toEqual({ id: "2", subject: "Task 2", description: "Details", status: "pending" }); }); + it("中止时不应创建任务、持久化或发送更新", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const sendEvent = vi.fn(); + const { tools, tasks } = createTaskTools({ onSave, sendEvent }); + const create = tools.find((tool) => tool.definition.name === "create_task")!; + const controller = new AbortController(); + controller.abort(); + + await expect(create.executor.execute({ subject: "不应创建" }, controller.signal)).rejects.toThrow("Aborted"); + expect(tasks.size).toBe(0); + expect(onSave).not.toHaveBeenCalled(); + expect(sendEvent).not.toHaveBeenCalled(); + }); + it("update_task 应更新任务字段", async () => { const { tools } = createTaskTools(); const create = tools.find((t) => t.definition.name === "create_task")!; diff --git a/src/app/service/agent/core/tools/task_tools.ts b/src/app/service/agent/core/tools/task_tools.ts index 5dd4d8690..480218916 100644 --- a/src/app/service/agent/core/tools/task_tools.ts +++ b/src/app/service/agent/core/tools/task_tools.ts @@ -1,5 +1,6 @@ import type { ToolDefinition, ChatStreamEvent } from "@App/app/service/agent/core/types"; import type { ToolExecutor } from "@App/app/service/agent/core/tool_registry"; +import { throwIfAborted } from "../abort_utils"; import { requireString, optionalString } from "./param_utils"; export type Task = { @@ -84,11 +85,13 @@ export function createTaskTools(options?: TaskToolsOptions): { } // 持久化并推送事件 - const emitUpdate = async () => { + const emitUpdate = async (signal?: AbortSignal) => { const taskList = Array.from(tasks.values()); + throwIfAborted(signal); if (options?.onSave) { await options.onSave(taskList); } + throwIfAborted(signal); if (options?.sendEvent) { options.sendEvent({ type: "task_update", @@ -103,21 +106,24 @@ export function createTaskTools(options?: TaskToolsOptions): { }; const createExecutor: ToolExecutor = { - execute: async (args: Record) => { + execute: async (args: Record, signal?: AbortSignal) => { + throwIfAborted(signal); const task: Task = { id: String(nextId++), subject: requireString(args, "subject"), description: optionalString(args, "description"), status: "pending", }; + throwIfAborted(signal); tasks.set(task.id, task); - await emitUpdate(); + await emitUpdate(signal); return JSON.stringify(task); }, }; const updateExecutor: ToolExecutor = { - execute: async (args: Record) => { + execute: async (args: Record, signal?: AbortSignal) => { + throwIfAborted(signal); const taskId = requireString(args, "task_id"); const task = tasks.get(taskId); if (!task) { @@ -126,13 +132,15 @@ export function createTaskTools(options?: TaskToolsOptions): { if (args.status) task.status = args.status as Task["status"]; if (args.subject) task.subject = args.subject as string; if (args.description !== undefined) task.description = args.description as string; - await emitUpdate(); + throwIfAborted(signal); + await emitUpdate(signal); return JSON.stringify(task); }, }; const listExecutor: ToolExecutor = { - execute: async () => { + execute: async (_args: Record, signal?: AbortSignal) => { + throwIfAborted(signal); return JSON.stringify(Array.from(tasks.values())); }, }; diff --git a/src/app/service/agent/core/tools/web_fetch.ts b/src/app/service/agent/core/tools/web_fetch.ts index 2d18efd52..ccbcafb4a 100644 --- a/src/app/service/agent/core/tools/web_fetch.ts +++ b/src/app/service/agent/core/tools/web_fetch.ts @@ -3,6 +3,7 @@ import type { ToolExecutor } from "@App/app/service/agent/core/tool_registry"; import type { MessageSend } from "@Packages/message/types"; import { extractHtmlContent } from "@App/app/service/offscreen/client"; import { requireString, optionalNumber } from "./param_utils"; +import { raceWithAbort, throwIfAborted } from "../abort_utils"; // Agent User-Agent 字符串 const AGENT_USER_AGENT = "Mozilla/5.0 (compatible; ScriptCat Agent)"; @@ -38,16 +39,16 @@ export function stripHtmlTags(html: string): string { } export class WebFetchExecutor implements ToolExecutor { - private summarize?: (content: string, prompt: string) => Promise; + private summarize?: (content: string, prompt: string, signal?: AbortSignal) => Promise; constructor( private sender: MessageSend, - deps?: { summarize?: (content: string, prompt: string) => Promise } + deps?: { summarize?: (content: string, prompt: string, signal?: AbortSignal) => Promise } ) { this.summarize = deps?.summarize; } - async execute(args: Record): Promise { + async execute(args: Record, signal?: AbortSignal): Promise { const url = requireString(args, "url"); const prompt = requireString(args, "prompt"); const maxLength = optionalNumber(args, "max_length"); @@ -63,9 +64,11 @@ export class WebFetchExecutor implements ToolExecutor { throw new Error("Only http/https URLs are supported"); } + throwIfAborted(signal); + const response = await fetch(url, { headers: { "User-Agent": AGENT_USER_AGENT }, - signal: AbortSignal.timeout(30_000), + signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000), }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); @@ -73,9 +76,11 @@ export class WebFetchExecutor implements ToolExecutor { // 检测重定向:最终 URL 与请求 URL 不同 const finalUrl = response.url && response.url !== url ? response.url : undefined; + throwIfAborted(signal); const contentType = response.headers.get("content-type") || ""; const text = await response.text(); + throwIfAborted(signal); let content: string; let detectedType: string; @@ -94,7 +99,7 @@ export class WebFetchExecutor implements ToolExecutor { // b) Content-Type 含 html 或未知 → 送 Offscreen extractHtmlContent else if (contentType.includes("html") || !contentType) { try { - const extracted = await extractHtmlContent(this.sender, text); + const extracted = await raceWithAbort(extractHtmlContent(this.sender, text), signal); if (extracted && extracted.length > 50) { content = extracted; detectedType = "html"; @@ -103,7 +108,10 @@ export class WebFetchExecutor implements ToolExecutor { content = stripHtmlTags(text); detectedType = "text"; } - } catch { + } catch (error) { + if (error instanceof Error && error.message === "Aborted") { + throw error; + } // Offscreen 提取失败,降级 content = stripHtmlTags(text); detectedType = "text"; @@ -123,7 +131,7 @@ export class WebFetchExecutor implements ToolExecutor { // LLM 摘要 if (this.summarize) { - content = await this.summarize(content, prompt); + content = await this.summarize(content, prompt, signal); truncated = false; } diff --git a/src/app/service/agent/core/tools/web_search.ts b/src/app/service/agent/core/tools/web_search.ts index ee52ddbb0..fe1637b6e 100644 --- a/src/app/service/agent/core/tools/web_search.ts +++ b/src/app/service/agent/core/tools/web_search.ts @@ -5,6 +5,7 @@ import type { SearchConfigRepo } from "./search_config"; import { extractSearchResults, extractBingResults, extractBaiduResults } from "@App/app/service/offscreen/client"; import { withTimeout } from "@App/pkg/utils/with_timeout"; import { requireString, optionalNumber } from "./param_utils"; +import { throwIfAborted } from "../abort_utils"; // Agent User-Agent 字符串 const AGENT_USER_AGENT = "Mozilla/5.0 (compatible; ScriptCat Agent)"; @@ -49,7 +50,7 @@ export class WebSearchExecutor implements ToolExecutor { private configRepo: SearchConfigRepo ) {} - async execute(args: Record): Promise { + async execute(args: Record, signal?: AbortSignal): Promise { const query = requireString(args, "query"); const maxResults = Math.min(optionalNumber(args, "max_results") ?? 5, 10); @@ -57,14 +58,14 @@ export class WebSearchExecutor implements ToolExecutor { switch (config.engine) { case "google_custom": - return this.searchGoogle(query, maxResults, config.googleApiKey || "", config.googleCseId || ""); + return this.searchGoogle(query, maxResults, config.googleApiKey || "", config.googleCseId || "", signal); case "duckduckgo": - return this.searchDuckDuckGo(query, maxResults); + return this.searchDuckDuckGo(query, maxResults, signal); case "baidu": - return this.searchBaidu(query, maxResults); + return this.searchBaidu(query, maxResults, signal); case "bing": default: - return this.searchBing(query, maxResults); + return this.searchBing(query, maxResults, signal); } } @@ -79,49 +80,74 @@ export class WebSearchExecutor implements ToolExecutor { url: string, extractFn: (html: string) => Promise, engineName: string, - maxResults: number + maxResults: number, + signal?: AbortSignal ): Promise { + throwIfAborted(signal); + const response = await fetch(url, { headers: { "User-Agent": AGENT_USER_AGENT }, - signal: AbortSignal.timeout(SEARCH_TIMEOUT_MS), + signal: signal + ? AbortSignal.any([signal, AbortSignal.timeout(SEARCH_TIMEOUT_MS)]) + : AbortSignal.timeout(SEARCH_TIMEOUT_MS), }); if (!response.ok) { throw new Error(`${engineName} search failed: HTTP ${response.status}`); } const html = await response.text(); + throwIfAborted(signal); let results: SearchResult[] = []; let extractionFailed = false; try { // 提取函数走 Offscreen 通道,加 10s 超时防卡死 - results = await withTimeout(extractFn(html), 10_000, () => new Error("extract timeout")); - } catch { + results = await withTimeout(extractFn(html), 10_000, () => new Error("extract timeout"), signal); + } catch (error) { + if (error instanceof Error && error.message === "Aborted") { + throw error; + } extractionFailed = true; } return formatSearchResults(results.slice(0, maxResults), extractionFailed, engineName); } - private async searchDuckDuckGo(query: string, maxResults: number): Promise { + private async searchDuckDuckGo(query: string, maxResults: number, signal?: AbortSignal): Promise { const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`; - return this.fetchAndExtract(url, (html) => extractSearchResults(this.sender, html), "DuckDuckGo", maxResults); + return this.fetchAndExtract( + url, + (html) => extractSearchResults(this.sender, html), + "DuckDuckGo", + maxResults, + signal + ); } - private async searchBing(query: string, maxResults: number): Promise { + private async searchBing(query: string, maxResults: number, signal?: AbortSignal): Promise { const url = `https://www.bing.com/search?q=${encodeURIComponent(query)}`; - return this.fetchAndExtract(url, (html) => extractBingResults(this.sender, html), "Bing", maxResults); + return this.fetchAndExtract(url, (html) => extractBingResults(this.sender, html), "Bing", maxResults, signal); } - private async searchBaidu(query: string, maxResults: number): Promise { + private async searchBaidu(query: string, maxResults: number, signal?: AbortSignal): Promise { const url = `https://www.baidu.com/s?wd=${encodeURIComponent(query)}&rn=${maxResults}`; - return this.fetchAndExtract(url, (html) => extractBaiduResults(this.sender, html), "Baidu", maxResults); + return this.fetchAndExtract(url, (html) => extractBaiduResults(this.sender, html), "Baidu", maxResults, signal); } - private async searchGoogle(query: string, maxResults: number, apiKey: string, cseId: string): Promise { + private async searchGoogle( + query: string, + maxResults: number, + apiKey: string, + cseId: string, + signal?: AbortSignal + ): Promise { if (!apiKey || !cseId) { throw new Error("Google Custom Search requires API Key and CSE ID. Configure them in Agent Tool Settings."); } const url = `https://www.googleapis.com/customsearch/v1?key=${encodeURIComponent(apiKey)}&cx=${encodeURIComponent(cseId)}&q=${encodeURIComponent(query)}&num=${maxResults}`; - const response = await fetch(url, { signal: AbortSignal.timeout(SEARCH_TIMEOUT_MS) }); + const response = await fetch(url, { + signal: signal + ? AbortSignal.any([signal, AbortSignal.timeout(SEARCH_TIMEOUT_MS)]) + : AbortSignal.timeout(SEARCH_TIMEOUT_MS), + }); if (!response.ok) { const text = await response.text().catch(() => ""); diff --git a/src/app/service/agent/core/types.ts b/src/app/service/agent/core/types.ts index 54574d295..99f27f9ff 100644 --- a/src/app/service/agent/core/types.ts +++ b/src/app/service/agent/core/types.ts @@ -94,6 +94,8 @@ export type ChatMessage = { // tool 角色的消息需要关联到对应的 tool_call toolCallId?: string; error?: string; + // 错误分类码,如 "max_iterations";用于 UI 判断是否展示针对性操作(如"继续对话") + errorCode?: string; warning?: string; modelId?: string; usage?: TokenUsage; @@ -118,7 +120,13 @@ export type LLMStreamEvent = | { type: "thinking_delta"; delta: string } | { type: "tool_call_start"; toolCall: Omit } | { type: "tool_call_delta"; id: string; delta: string; index?: number } - | { type: "tool_call_complete"; id: string; result: string; attachments?: Attachment[] } + | { + type: "tool_call_complete"; + id: string; + result: string; + status?: "completed" | "error"; + attachments?: Attachment[]; + } | { type: "content_block_start"; block: Omit } | { type: "content_block_complete"; block: ImageBlock | FileBlock | AudioBlock; data?: string }; @@ -136,14 +144,30 @@ export type ForwardableEvent = }; durationMs?: number; } - | { type: "error"; message: string; errorCode?: string } + | { + type: "error"; + message: string; + errorCode?: string; + usage?: TokenUsage; + durationMs?: number; + } | { type: "retry"; attempt: number; maxRetries: number; error: string; delayMs: number }; // Service Worker -> UI/Sandbox 的流式事件(通过 MessageConnect 的 sendMessage 传输) // ForwardableEvent 携带可选 subAgent 标识(扁平化子代理事件,消除递归包装) export type ChatStreamEvent = | (ForwardableEvent & { subAgent?: SubAgentEventInfo }) - | { type: "ask_user"; id: string; question: string; options?: string[]; multiple?: boolean } + | { + type: "ask_user"; + id: string; + question: string; + options?: string[]; + optionValues?: string[]; + multiple?: boolean; + allowCustom?: boolean; + } + | { type: "ask_user_expired"; id: string } + | { type: "ask_user_resolved"; id: string } | { type: "system_warning"; message: string } | { type: "task_update"; @@ -158,7 +182,14 @@ export type ChatStreamEvent = | { type: "sync"; streamingMessage?: { content: string; thinking?: string; toolCalls: ToolCall[] }; - pendingAskUser?: { id: string; question: string; options?: string[]; multiple?: boolean }; + pendingAskUser?: { + id: string; + question: string; + options?: string[]; + optionValues?: string[]; + multiple?: boolean; + allowCustom?: boolean; + }; tasks: Array<{ id: string; subject: string; @@ -245,12 +276,21 @@ export type ChatReply = { cacheCreationInputTokens?: number; cacheReadInputTokens?: number; }; + durationMs?: number; command?: boolean; // 标识该回复来自命令处理 }; // conv.chatStream() 的流式 chunk export type StreamChunk = { - type: "content_delta" | "thinking_delta" | "tool_call" | "content_block" | "done" | "error"; + type: + | "content_delta" + | "thinking_delta" + | "tool_call" + | "tool_call_complete" + | "content_block" + | "new_message" + | "done" + | "error"; content?: string; block?: ContentBlock; toolCall?: ToolCall; @@ -260,6 +300,7 @@ export type StreamChunk = { cacheCreationInputTokens?: number; cacheReadInputTokens?: number; }; + durationMs?: number; error?: string; /** 错误分类码:"rate_limit" | "auth" | "tool_timeout" | "max_iterations" | "api_error" */ errorCode?: string; diff --git a/src/app/service/agent/service_worker/agent.ts b/src/app/service/agent/service_worker/agent.ts index 37661d45d..b337141f1 100644 --- a/src/app/service/agent/service_worker/agent.ts +++ b/src/app/service/agent/service_worker/agent.ts @@ -36,14 +36,15 @@ import { AgentTaskScheduler } from "@App/app/service/agent/core/task_scheduler"; import { WEB_FETCH_DEFINITION, WebFetchExecutor } from "@App/app/service/agent/core/tools/web_fetch"; import { WEB_SEARCH_DEFINITION, WebSearchExecutor } from "@App/app/service/agent/core/tools/web_search"; import { SearchConfigRepo, type SearchEngineConfig } from "@App/app/service/agent/core/tools/search_config"; +import { AgentConfigRepo, type AgentGeneralConfig } from "@App/app/service/agent/core/agent_config"; import { SubAgentService } from "./sub_agent_service"; import { BackgroundSessionManager } from "./background_session_manager"; import { createOPFSTools, setCreateBlobUrlFn } from "@App/app/service/agent/core/tools/opfs_tools"; -import { createObjectURL } from "@App/app/service/offscreen/client"; import { AgentOPFSService } from "./opfs_service"; -import { executeSkillScript } from "@App/app/service/offscreen/client"; +import { createObjectURL, executeSkillScript, stopScript } from "@App/app/service/offscreen/client"; import { createTabTools } from "@App/app/service/agent/core/tools/tab_tools"; import { ChatService } from "./chat_service"; +import { createAbortError, throwIfAborted } from "@App/app/service/agent/core/abort_utils"; // 保留对外 API(测试文件直接从 "./agent" import 这三个函数) export { isRetryableError, withRetry, classifyErrorCode } from "./retry_utils"; @@ -66,6 +67,7 @@ export class AgentService { // 定时任务逻辑委托给 AgentTaskService private agentTaskService!: AgentTaskService; private searchConfigRepo = new SearchConfigRepo(); + private agentConfigRepo = new AgentConfigRepo(); // 后台运行的会话注册表(委托给 BackgroundSessionManager) private bgSessionManager = new BackgroundSessionManager(); // 子代理编排逻辑委托给 SubAgentService @@ -118,22 +120,40 @@ export class AgentService { this.subAgentService, { executeInPage: (code, options) => this.domService.executeScript(code, options), - executeInSandbox: (code) => { + executeInSandbox: (code: string, signal?: AbortSignal) => { + throwIfAborted(signal); const uuid = SKILL_SCRIPT_UUID_PREFIX + uuidv4(); - return executeSkillScript(this.sender, { + const execPromise = executeSkillScript(this.sender, { uuid, code, args: {}, grants: [], name: "execute_script", }); + if (!signal) return execPromise; + return new Promise((resolve, reject) => { + let aborted = false; + const onAbort = () => { + if (aborted) return; + aborted = true; + void stopScript(this.sender, uuid); + reject(createAbortError()); + }; + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) { + onAbort(); + return; + } + execPromise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort)); + }); }, }, { callLLM: (model, params, sendEvent, signal) => this.callLLM(model, params, sendEvent, signal), callLLMWithToolLoop: (params) => this.callLLMWithToolLoop(params), }, - agentChatRepo + agentChatRepo, + this.agentConfigRepo ); } @@ -214,11 +234,14 @@ export class AgentService { // 搜索配置 API(供 Options UI 调用) this.group.on("getSearchConfig", () => this.searchConfigRepo.getConfig()); this.group.on("saveSearchConfig", (config: SearchEngineConfig) => this.searchConfigRepo.saveConfig(config)); + // Agent 通用设置 API(供 Options UI 调用) + this.group.on("getAgentConfig", () => this.agentConfigRepo.getConfig()); + this.group.on("saveAgentConfig", (config: AgentGeneralConfig) => this.agentConfigRepo.saveConfig(config)); // 注册永久内置工具 this.toolRegistry.registerBuiltin( WEB_FETCH_DEFINITION, new WebFetchExecutor(this.sender, { - summarize: (content, prompt) => this.summarizeContent(content, prompt), + summarize: (content, prompt, signal) => this.summarizeContent(content, prompt, signal), }) ); this.toolRegistry.registerBuiltin(WEB_SEARCH_DEFINITION, new WebSearchExecutor(this.sender, this.searchConfigRepo)); @@ -235,7 +258,7 @@ export class AgentService { // 注册 Tab 操作工具 const tabTools = createTabTools({ sender: this.sender, - summarize: (content, prompt) => this.summarizeContent(content, prompt), + summarize: (content, prompt, signal) => this.summarizeContent(content, prompt, signal), }); for (const t of tabTools.tools) { this.toolRegistry.registerBuiltin(t.definition, t.executor); @@ -396,8 +419,8 @@ export class AgentService { // 对内容做摘要/提取(供 tab 工具使用) // 优先使用摘要模型,fallback 到默认模型 - private async summarizeContent(content: string, prompt: string): Promise { - return this.compactService.summarizeContent(content, prompt); + private async summarizeContent(content: string, prompt: string, signal?: AbortSignal): Promise { + return this.compactService.summarizeContent(content, prompt, signal); } // 调用 LLM 并收集完整响应(委托给 LLMClient) diff --git a/src/app/service/agent/service_worker/autocompact.test.ts b/src/app/service/agent/service_worker/autocompact.test.ts index 9544fac08..f2718563f 100644 --- a/src/app/service/agent/service_worker/autocompact.test.ts +++ b/src/app/service/agent/service_worker/autocompact.test.ts @@ -16,7 +16,7 @@ describe("Compact 功能", () => { function makeTextResponseWithTokens(text: string, promptTokens = 10): Response { return makeSSEResponse([ - `data: {"choices":[{"delta":{"content":${JSON.stringify(text)}}}]}\n\n`, + `data: {"choices":[{"delta":{"content":${JSON.stringify(text)}},"finish_reason":"stop"}]}\n\n`, `data: {"usage":{"prompt_tokens":${promptTokens},"completion_tokens":5}}\n\n`, ]); } @@ -99,6 +99,66 @@ describe("Compact 功能", () => { expect(lastUserMsg.content).toContain("只保留代码"); }); + it("手动 compact:先过滤错误占位消息并裁剪超出上下文阈值的旧工具结果", async () => { + const { service, mockRepo, mockModelRepo } = createTestService(); + const { sender } = createMockSender(); + mockModelRepo.getModel.mockResolvedValue({ + id: "test-openai", + name: "Test", + provider: "openai", + apiBaseUrl: "", + apiKey: "", + model: "gpt-4o", + // 提高到 40000:getReservedOutputTokens 现在对未显式配置 maxTokens 的 OpenAI 兼容模型 + // 也预留一个非零默认输出额度(见 model_context.ts,finding 11),10000 的窗口在扣除 + // 预留额度后输入预算会塌缩到 0,这里相应放宽窗口,保留测试原本想验证的"裁剪旧工具结果"场景 + contextWindow: 40000, + }); + mockRepo.listConversations.mockResolvedValue([BASE_CONV]); + const messages: any[] = []; + for (let i = 0; i < 6; i++) { + messages.push({ + id: `a${i}`, + conversationId: "conv-1", + role: "assistant", + content: "", + toolCalls: [{ id: `tc${i}`, name: "tool", arguments: "{}" }], + createtime: i, + }); + messages.push({ + id: `t${i}`, + conversationId: "conv-1", + role: "tool", + content: "工具结果".repeat(500), + toolCallId: `tc${i}`, + createtime: i, + }); + } + messages.push({ + id: "error", + conversationId: "conv-1", + role: "assistant", + content: "", + error: "max iterations", + errorCode: "max_iterations", + createtime: 99, + }); + mockRepo.getMessages.mockResolvedValue(messages); + fetchSpy.mockResolvedValueOnce(makeTextResponseWithTokens("压缩完成")); + + await (service as any).handleConversationChat({ conversationId: "conv-1", message: "", compact: true }, sender); + + const body = JSON.parse(fetchSpy.mock.calls[0][1].body as string); + expect( + body.messages.some( + (message: any) => message.role === "assistant" && message.content === "" && !message.tool_calls + ) + ).toBe(false); + expect( + body.messages.some((message: any) => typeof message.content === "string" && message.content.includes("elided")) + ).toBe(true); + }); + it("手动 compact:空消息时返回错误", async () => { const { service, mockRepo } = createTestService(); const { sender, sentMessages } = createMockSender(); @@ -145,7 +205,7 @@ describe("Compact 功能", () => { // 第一次 LLM 调用:返回文本但 inputTokens 超过 80% (110000/128000 ≈ 86%) fetchSpy.mockResolvedValueOnce( makeSSEResponse([ - `data: {"choices":[{"delta":{"content":"Some response"}}]}\n\n`, + `data: {"choices":[{"delta":{"content":"Some response"},"finish_reason":"stop"}]}\n\n`, `data: {"usage":{"prompt_tokens":110000,"completion_tokens":100}}\n\n`, ]) ); @@ -178,7 +238,7 @@ describe("Compact 功能", () => { // inputTokens = 50000, contextWindow(gpt-4o) = 128000, 39% < 80% fetchSpy.mockResolvedValueOnce( makeSSEResponse([ - `data: {"choices":[{"delta":{"content":"OK"}}]}\n\n`, + `data: {"choices":[{"delta":{"content":"OK"},"finish_reason":"stop"}]}\n\n`, `data: {"usage":{"prompt_tokens":50000,"completion_tokens":5}}\n\n`, ]) ); diff --git a/src/app/service/agent/service_worker/background.test.ts b/src/app/service/agent/service_worker/background.test.ts index 82db11843..9f1ef4ac5 100644 --- a/src/app/service/agent/service_worker/background.test.ts +++ b/src/app/service/agent/service_worker/background.test.ts @@ -1,5 +1,21 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { createTestService, makeTextResponse, createRunningConversation } from "./test-helpers"; +import { createTestService, makeTextResponse, makeSSEResponse, createRunningConversation } from "./test-helpers"; + +// 与 llm.test.ts 中的同名辅助函数一致:构造带 tool_call 的 OpenAI SSE 响应 +function makeToolCallResponse(toolCalls: Array<{ id: string; name: string; arguments: string }>): Response { + const chunks: string[] = []; + toolCalls.forEach((tc, i) => { + chunks.push( + `data: {"choices":[{"delta":{"tool_calls":[{"id":"${tc.id}","function":{"name":"${tc.name}","arguments":""}}]}}]}\n\n` + ); + const isLast = i === toolCalls.length - 1; + chunks.push( + `data: {"choices":[{"delta":{"tool_calls":[{"function":{"arguments":${JSON.stringify(tc.arguments)}}}]}${isLast ? ', "finish_reason":"tool_calls"' : ""}}]}\n\n` + ); + }); + chunks.push(`data: {"usage":{"prompt_tokens":10,"completion_tokens":5}}\n\n`); + return makeSSEResponse(chunks); +} // ---- updateStreamingState 快照状态管理 ---- @@ -312,6 +328,55 @@ describe("handleAttachToConversation 重连逻辑", () => { (service as any).bgSessionManager.delete("conv-done"); }); + it("cancelling 阶段 attach:sync 仍报 running 并继续订阅 listener,直到真正的终态事件(finding 1)", async () => { + const { service } = createTestService(); + const rc = createRunningConversation({ status: "cancelling" }); + (service as any).bgSessionManager.set("conv-cancelling", rc); + + const { sender, sentMessages } = createMockSender(); + await (service as any).handleAttachToConversation({ conversationId: "conv-cancelling" }, sender); + + const syncEvent = sentMessages.find((m: any) => m.action === "event" && m.data.type === "sync"); + // 不能报 error:那会让客户端立即断开重载,永远收不到 orchestrator 后续广播的真正终态事件 + expect(syncEvent.data.status).toBe("running"); + expect(rc.listeners.size).toBe(1); + + // 之后 orchestrator 广播真正的终态事件时,这个迟到的 listener 应该能收到 + (service as any).bgSessionManager.broadcastEvent(rc, { + type: "error", + message: "Conversation cancelled", + errorCode: "cancelled", + usage: { inputTokens: 1, outputTokens: 2 }, + }); + const terminalEvent = sentMessages.find((m: any) => m.data?.errorCode === "cancelled"); + expect(terminalEvent).toBeDefined(); + expect(terminalEvent.data.usage).toBeDefined(); + + (service as any).bgSessionManager.delete("conv-cancelling"); + }); + + it("finalizeCancelled 在 status 已被终态事件改写为 error 后仍应调度清理(finding 1)", async () => { + vi.useFakeTimers(); + try { + const { service } = createTestService(); + const rc = createRunningConversation({ status: "cancelling" }); + (service as any).bgSessionManager.set("conv-late-error", rc); + + // 模拟 emitCancelled() 广播的终态事件已经先经过 sendEvent → updateStreamingState + // 把 status 从 cancelling 改写成 error(真实时序:chat_service_base.ts 的 sendEvent + // 总是先 updateStreamingState 再 finalizeCancelled 才被调用) + (service as any).bgSessionManager.updateStreamingState(rc, { type: "error", message: "x" }); + expect(rc.status).toBe("error"); + + (service as any).bgSessionManager.finalizeCancelled("conv-late-error", rc); + + await vi.advanceTimersByTimeAsync(30_000); + expect((service as any).bgSessionManager.get("conv-late-error")).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); + it("运行中的会话添加 listener,断开时移除", async () => { const { service } = createTestService(); const rc = createRunningConversation({ status: "running" }); @@ -329,19 +394,21 @@ describe("handleAttachToConversation 重连逻辑", () => { (service as any).bgSessionManager.delete("conv-run"); }); - it("通过 attach 发送 askUserResponse 能正确 resolve", async () => { + it("通过 attach 发送 askUserResponse 能正确 resolve,且只广播一次 ask_user_resolved", async () => { const { service } = createTestService(); const rc = createRunningConversation({ status: "running" }); - // 注册一个 askResolver + // 真实的 resolver(ask_user.ts / askUserForGuard)在 resolve 时会自行广播终态事件, + // attach 不应该再额外广播一次,否则同一次回答会产生两条 ask_user_resolved let resolvedAnswer: string | undefined; rc.askResolvers.set("ask-1", (answer: string) => { resolvedAnswer = answer; + (service as any).bgSessionManager.broadcastEvent(rc, { type: "ask_user_resolved", id: "ask-1" }); }); rc.pendingAskUser = { id: "ask-1", question: "选择" }; (service as any).bgSessionManager.set("conv-ask", rc); - const { sender, simulateMessage } = createMockSender(); + const { sender, sentMessages, simulateMessage } = createMockSender(); await (service as any).handleAttachToConversation({ conversationId: "conv-ask" }, sender); // 模拟 UI 回复 ask_user @@ -350,11 +417,13 @@ describe("handleAttachToConversation 重连逻辑", () => { expect(resolvedAnswer).toBe("红色"); expect(rc.pendingAskUser).toBeUndefined(); expect(rc.askResolvers.has("ask-1")).toBe(false); + const resolvedEvents = sentMessages.filter((message) => message.data?.type === "ask_user_resolved"); + expect(resolvedEvents).toHaveLength(1); (service as any).bgSessionManager.delete("conv-ask"); }); - it("多个 listener 回复同一 ask_user 只有第一个生效", async () => { + it("多个 listener 回复同一 ask_user 只有第一个生效,且每个 listener 只收到一次 ask_user_resolved", async () => { const { service } = createTestService(); const rc = createRunningConversation({ status: "running" }); @@ -363,12 +432,13 @@ describe("handleAttachToConversation 重连逻辑", () => { rc.askResolvers.set("ask-1", (answer: string) => { resolveCount++; lastAnswer = answer; + (service as any).bgSessionManager.broadcastEvent(rc, { type: "ask_user_resolved", id: "ask-1" }); }); rc.pendingAskUser = { id: "ask-1", question: "选择" }; (service as any).bgSessionManager.set("conv-multi", rc); - const { sender: sender1, simulateMessage: sim1 } = createMockSender(); - const { sender: sender2, simulateMessage: sim2 } = createMockSender(); + const { sender: sender1, sentMessages: sent1, simulateMessage: sim1 } = createMockSender(); + const { sender: sender2, sentMessages: sent2, simulateMessage: sim2 } = createMockSender(); await (service as any).handleAttachToConversation({ conversationId: "conv-multi" }, sender1); await (service as any).handleAttachToConversation({ conversationId: "conv-multi" }, sender2); @@ -378,23 +448,65 @@ describe("handleAttachToConversation 重连逻辑", () => { expect(resolveCount).toBe(1); expect(lastAnswer).toBe("第一个"); + expect(sent1.filter((message) => message.data?.type === "ask_user_resolved")).toHaveLength(1); + expect(sent2.filter((message) => message.data?.type === "ask_user_resolved")).toHaveLength(1); (service as any).bgSessionManager.delete("conv-multi"); }); - it("通过 attach 发送 stop 能中止会话", async () => { - const { service } = createTestService(); - const rc = createRunningConversation({ status: "running" }); - (service as any).bgSessionManager.set("conv-stop", rc); - - const { sender, simulateMessage } = createMockSender(); - await (service as any).handleAttachToConversation({ conversationId: "conv-stop" }, sender); + it("通过 attach 发送 stop 只置为 cancelling 并 abort,不自行广播终态(终态事件唯一来源是执行方 emitCancelled)", async () => { + vi.useFakeTimers(); + try { + const { service } = createTestService(); + const rc = createRunningConversation({ + status: "running", + pendingAskUser: { id: "ask-1", question: "继续吗" }, + }); + rc.askResolvers.set("ask-1", vi.fn()); + (service as any).bgSessionManager.set("conv-stop", rc); + + const { sender, sentMessages, simulateMessage } = createMockSender(); + await (service as any).handleAttachToConversation({ conversationId: "conv-stop" }, sender); + + simulateMessage({ action: "stop" }); + + expect(rc.abortController.signal.aborted).toBe(true); + // stop() 只置为 cancelling:真正的终态由持有该 rc 的执行方 promise 落定后写入, + // 避免同一 conversationId 在旧执行尚未退出时就被新会话顶替(见 finding 1) + expect(rc.status).toBe("cancelling"); + expect(rc.pendingAskUser).toBeUndefined(); + expect(rc.askResolvers.size).toBe(0); + expect((service as any).bgSessionManager.has("conv-stop")).toBe(true); + // cancelling 现在也纳入发现列表:UI 侧(Options 页面刷新/重连)需要据此判断是否 + // 应该继续 attach 等待真正的终态事件,而不是误判为"已经不在运行"(见 finding 6) + expect(service.getRunningConversationIds()).toContain("conv-stop"); + // stop() 本身不再广播任何终态事件:唯一的终态事件来自执行方(orchestrator 的 + // emitCancelled,走正常 sendEvent → updateStreamingState → broadcastEvent 路径), + // 避免"先广播一条不带 usage 的事件,UI 断开后丢失后到的真实终态事件"的竞态 + expect(sentMessages.some((message) => message.data?.type === "error")).toBe(false); + + // 模拟执行方在 abort 落定后调用 finalizeCancelled + (service as any).bgSessionManager.finalizeCancelled("conv-stop", rc); + expect(rc.status).toBe("error"); + expect((service as any).bgSessionManager.has("conv-stop")).toBe(false); + + await vi.advanceTimersByTimeAsync(30_000); + expect((service as any).bgSessionManager.get("conv-stop")).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); - simulateMessage({ action: "stop" }); + it("stop() 传入 expectedRc 与当前会话实例不符时应忽略,避免旧连接的延迟 Stop 误伤新会话", async () => { + const { service } = createTestService(); + const staleRc = createRunningConversation({ status: "running" }); + const currentRc = createRunningConversation({ status: "running" }); + (service as any).bgSessionManager.set("conv-race", currentRc); - expect(rc.abortController.signal.aborted).toBe(true); + (service as any).bgSessionManager.stop("conv-race", staleRc); - (service as any).bgSessionManager.delete("conv-stop"); + expect(currentRc.abortController.signal.aborted).toBe(false); + expect(currentRc.status).toBe("running"); }); it("空 streamingState 的 sync 不包含 streamingMessage 字段", async () => { @@ -494,7 +606,7 @@ describe("后台运行会话 集成测试", () => { if (readCalled === 1) { return { done: false, - value: encoder.encode(`data: {"choices":[{"delta":{"content":"hello"}}]}\n\n`), + value: encoder.encode(`data: {"choices":[{"delta":{"content":"hello"},"finish_reason":"stop"}]}\n\n`), }; } if (readCalled === 2) { @@ -595,11 +707,11 @@ describe("后台运行会话 集成测试", () => { expect(events.some((e: any) => e.type === "done")).toBe(true); }); - it("后台模式:stop 指令中止会话后不抛未捕获异常", async () => { + it("后台模式:stop 指令中止会话后会广播终态而且不抛未捕获异常", async () => { const { service, mockRepo } = createTestService(); setupConversation(mockRepo); - const { sender, simulateMessage } = createMockSender(); + const { sender, sentMessages, simulateMessage } = createMockSender(); fetchSpy.mockImplementation(async (_url: any, init: any) => { if (init?.signal?.aborted) { @@ -621,7 +733,59 @@ describe("后台运行会话 集成测试", () => { simulateMessage({ action: "stop" }); await chatPromise; - // 不应抛异常 + const rc = (service as any).bgSessionManager.get("conv-bg"); + expect(rc.status).toBe("error"); + expect( + sentMessages.some((message) => message.data?.type === "error" && message.data.errorCode === "cancelled") + ).toBe(true); + }); + + it("循环检测升级提问超时(5 分钟无人应答)后,应清除 rc.pendingAskUser,避免后续 attach 看到过期提问", async () => { + vi.useFakeTimers(); + try { + const { service, mockRepo } = createTestService(); + setupConversation(mockRepo); + const { sender, sentMessages } = createMockSender(); + + const registry = (service as any).toolRegistry; + registry.registerBuiltin( + { name: "dup", description: "dup", parameters: { type: "object", properties: {} } }, + { execute: async () => "ok" } + ); + + // 连续 4 轮相同参数调用同一工具,触发两次循环检测告警(第 2、4 轮), + // 第二次告警会暂停并调用 askUserForGuard;第 5 轮永不 resolve, + // 用于在会话真正结束(发出 done 事件)之前观测超时后的瞬时状态—— + // 如果只等到 done 事件才检查,done 处理器本身就会清掉 pendingAskUser, + // 从而掩盖“超时未主动清除”这个问题 + fetchSpy + .mockResolvedValueOnce(makeToolCallResponse([{ id: "c1", name: "dup", arguments: "{}" }])) + .mockResolvedValueOnce(makeToolCallResponse([{ id: "c2", name: "dup", arguments: "{}" }])) + .mockResolvedValueOnce(makeToolCallResponse([{ id: "c3", name: "dup", arguments: "{}" }])) + .mockResolvedValueOnce(makeToolCallResponse([{ id: "c4", name: "dup", arguments: "{}" }])) + .mockReturnValueOnce(new Promise(() => {})); + + void (service as any).handleConversationChat( + { conversationId: "conv-bg", message: "test", background: true }, + sender + ); + + // 推进 5 分钟,让 askUserForGuard 的超时触发(默认按 Continue 继续), + // 此时第 5 轮请求已发出但永不 resolve,会话仍处于 running,尚未发出 done 事件 + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + + const rc = (service as any).bgSessionManager.get("conv-bg"); + expect(rc).toBeDefined(); + expect(rc.status).toBe("running"); + expect(rc.pendingAskUser).toBeUndefined(); + expect(sentMessages.some((message) => message.data?.type === "ask_user_expired")).toBe(true); + // 超时属于“过期”而非“已回答”:不应在发出 ask_user_expired 后又紧接着发出 ask_user_resolved + expect(sentMessages.some((message) => message.data?.type === "ask_user_resolved")).toBe(false); + + registry.unregisterBuiltin("dup"); + } finally { + vi.useRealTimers(); + } }); it("getRunningConversationIds 返回正确的 ID 列表", () => { @@ -633,9 +797,9 @@ describe("后台运行会话 集成测试", () => { (service as any).bgSessionManager.set("conv-2", { status: "done" }); const ids = service.getRunningConversationIds(); - expect(ids).toHaveLength(2); + expect(ids).toHaveLength(1); expect(ids).toContain("conv-1"); - expect(ids).toContain("conv-2"); + expect(ids).not.toContain("conv-2"); (service as any).bgSessionManager.delete("conv-1"); (service as any).bgSessionManager.delete("conv-2"); diff --git a/src/app/service/agent/service_worker/background_session_manager.ts b/src/app/service/agent/service_worker/background_session_manager.ts index de92f558e..c363e661f 100644 --- a/src/app/service/agent/service_worker/background_session_manager.ts +++ b/src/app/service/agent/service_worker/background_session_manager.ts @@ -13,10 +13,19 @@ export type RunningConversation = { abortController: AbortController; listeners: Set; streamingState: { content: string; thinking: string; toolCalls: ToolCall[] }; - pendingAskUser?: { id: string; question: string; options?: string[]; multiple?: boolean }; + pendingAskUser?: { + id: string; + question: string; + options?: string[]; + optionValues?: string[]; + multiple?: boolean; + allowCustom?: boolean; + }; askResolvers: Map void>; tasks: Array<{ id: string; subject: string; status: "pending" | "in_progress" | "completed"; description?: string }>; - status: "running" | "done" | "error"; + // cancelling:stop() 已触发但执行方尚未真正退出(promise 未 settle); + // 在此期间该 conversationId 仍视为"占用中",避免同 ID 的替换会话被过早放行。 + status: "running" | "cancelling" | "done" | "error"; }; // 后台会话注册表:管理流式状态快照、listener 广播、UI 附加逻辑 @@ -24,7 +33,8 @@ export class BackgroundSessionManager { private runningConversations = new Map(); has(conversationId: string): boolean { - return this.runningConversations.has(conversationId); + const status = this.runningConversations.get(conversationId)?.status; + return status === "running" || status === "cancelling"; } get(conversationId: string): RunningConversation | undefined { @@ -39,8 +49,13 @@ export class BackgroundSessionManager { this.runningConversations.delete(conversationId); } + // cancelling 也算在内:handleAttach() 已经支持对 cancelling 会话继续订阅直到真正的终态 + // 事件,但如果这里不把 cancelling 纳入发现列表,UI 侧(Options 页面刷新/重连)永远不会 + // 尝试 attach,也就永远等不到那条真正携带取消原因/usage 的终态事件(见 finding 6) listIds(): string[] { - return Array.from(this.runningConversations.keys()); + return Array.from(this.runningConversations.entries()) + .filter(([, conversation]) => conversation.status === "running" || conversation.status === "cancelling") + .map(([conversationId]) => conversationId); } // 更新后台会话的流式状态快照 @@ -89,7 +104,7 @@ export class BackgroundSessionManager { case "tool_call_complete": { const tc = rc.streamingState.toolCalls.find((t) => t.id === event.id); if (tc) { - tc.status = "completed"; + tc.status = event.status || "completed"; tc.result = event.result; tc.attachments = event.attachments; } @@ -104,9 +119,17 @@ export class BackgroundSessionManager { id: event.id, question: event.question, options: event.options, + optionValues: event.optionValues, multiple: event.multiple, + allowCustom: event.allowCustom, }; break; + case "ask_user_expired": + if (rc.pendingAskUser?.id === event.id) rc.pendingAskUser = undefined; + break; + case "ask_user_resolved": + if (rc.pendingAskUser?.id === event.id) rc.pendingAskUser = undefined; + break; case "task_update": rc.tasks = event.tasks; break; @@ -132,6 +155,38 @@ export class BackgroundSessionManager { } } + // 停止后台会话:仅置为 cancelling(占用态,阻止同 ID 会话被过早顶替)并 abort, + // 不在这里广播终态事件。终态事件的唯一来源是执行方(orchestrator 的 emitCancelled, + // 见 tool_loop_orchestrator_base.ts)在 promise 真正落定后发出的那一条——它携带完整的 + // 累计 usage/耗时。此前 stop() 自己先广播一条不带 usage 的 error:cancelled,会导致 UI + // 连接在收到 orchestrator 发出的、携带真实 usage 的终态事件之前就已断开丢弃它, + // 也会让 finalizeCancelled() 因为 status 已被后到的 error 事件提前改写而永远不触发清理。 + // expectedRc 用于校验调用方持有的会话实例仍是当前会话,防止旧连接的延迟 Stop 误伤已顶替上位的新会话。 + stop(conversationId: string, expectedRc?: RunningConversation): void { + const rc = this.runningConversations.get(conversationId); + if (!rc || (expectedRc && rc !== expectedRc)) return; + if (rc.status !== "running") return; + + rc.status = "cancelling"; + rc.pendingAskUser = undefined; + rc.askResolvers.clear(); + rc.abortController.abort(); + } + + // 执行方在 abort 落定、promise 真正 settle 后调用,把 cancelling 收敛为终态并调度清理。 + // 幂等:emitCancelled() 广播的终态事件会先经过正常的 sendEvent → updateStreamingState + // 把 status 从 cancelling 改写成 error,等这里再执行时 status 已经不是 cancelling 了—— + // 之前的实现只认 cancelling,导致这个真实存在的时序下 cleanupIfDone() 永远不会被调度, + // 记录永久留在 runningConversations 里。这里改为:cancelling 或已经落定的终态都视为 + // 可以安全调度清理(cleanupIfDone 自身按实例比对 + status!=="running" 做二次确认,幂等安全)。 + // 通过实例比对避免误将已被新会话顶替的 map 条目错误终态化。 + finalizeCancelled(conversationId: string, rc: RunningConversation): void { + if (this.runningConversations.get(conversationId) !== rc) return; + if (rc.status === "running") return; + if (rc.status === "cancelling") rc.status = "error"; + this.cleanupIfDone(conversationId); + } + // 附加 UI 连接到后台运行中的会话(同步快照 + listener + askUser resolver + stop) async handleAttach(params: { conversationId: string }, sender: IGetSender): Promise { if (!sender.isType(GetSenderType.CONNECT)) { @@ -164,12 +219,18 @@ export class BackgroundSessionManager { : undefined, pendingAskUser: rc.pendingAskUser, tasks: rc.tasks, - status: rc.status, + // cancelling 还没有产生真正携带取消原因/usage/耗时的终态事件(那条事件由 orchestrator 的 + // emitCancelled() 在 promise 落定后才广播,见 tool_loop_orchestrator_base.ts)。 + // 之前这里直接报 "error" 会让客户端立即断开重载,永远收不到那条真正完整的终态事件, + // 也可能在取消记录落库前就重载;改为对外仍按 "running" 处理并继续订阅 listener, + // 客户端会一直等到下面广播的那条真正的终态事件再断开。 + status: rc.status === "cancelling" ? "running" : rc.status, }; sendEvent(syncEvent); - // 已完成则不需要添加 listener - if (rc.status !== "running") { + // 只有已产生真正终态事件(done/error)才不需要 listener;cancelling 必须继续订阅 + // 直到 orchestrator 广播出那条真正的终态事件(见上面的注释) + if (rc.status !== "running" && rc.status !== "cancelling") { return; } @@ -184,11 +245,13 @@ export class BackgroundSessionManager { if (resolver) { rc.askResolvers.delete(msg.data.id); rc.pendingAskUser = undefined; + // resolver 自身(ask_user.ts / askUserForGuard)负责广播其终态事件, + // 这里不再重复广播,否则同一次回答会产生两条 ask_user_resolved resolver(msg.data.answer); } } if (msg.action === "stop") { - rc.abortController.abort(); + this.stop(params.conversationId, rc); } }); diff --git a/src/app/service/agent/service_worker/chat.test.ts b/src/app/service/agent/service_worker/chat.test.ts index f37dd5a80..73ca244a7 100644 --- a/src/app/service/agent/service_worker/chat.test.ts +++ b/src/app/service/agent/service_worker/chat.test.ts @@ -185,6 +185,36 @@ describe("handleConversationChat skipSaveUserMessage", () => { }); }); +describe("userscript 会话工具隔离", () => { + it("携带 scriptUuid 时不注册无法交互的 ask_user 工具", async () => { + const { service } = createTestService(); + const chatService = (service as any).chatService; + const result = await chatService.buildSessionToolRegistry({ + conv: { + id: "conv-script", + title: "Script", + modelId: "test-openai", + createtime: 1, + updatetime: 1, + }, + model: { + id: "test-openai", + name: "Test", + provider: "openai", + apiBaseUrl: "", + apiKey: "", + model: "gpt-4o", + }, + params: { conversationId: "conv-script", message: "hi", scriptUuid: "script-1" }, + sendEvent: vi.fn(), + abortController: new AbortController(), + askResolvers: new Map(), + }); + + expect(result.sessionRegistry.getDefinitions().some((tool: any) => tool.name === "ask_user")).toBe(false); + }); +}); + // ---- handleConversationChat 场景补充 ---- describe("handleConversationChat 场景补充", () => { @@ -508,3 +538,268 @@ describe.concurrent("handleModelApi", () => { ); }); }); + +// ---- 脚本工具回调:abort/disconnect/超时应结束等待,而不是永远挂起 ---- + +describe("scriptToolCallback 的 abort/disconnect/超时处理", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, "fetch"); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + // 带 message/disconnect 模拟回调的 mock sender(脚本工具不会主动回复 toolResults) + function createMockSender() { + const sentMessages: any[] = []; + let messageHandler: ((msg: any) => void) | null = null; + let disconnectHandler: (() => void) | null = null; + const mockConn = { + sendMessage: (msg: any) => sentMessages.push(msg), + onMessage: vi.fn((handler: any) => { + messageHandler = handler; + }), + onDisconnect: vi.fn((handler: any) => { + disconnectHandler = handler; + }), + }; + const sender = { + isType: (type: any) => type === 1, + getConnect: () => mockConn, + }; + return { + sender, + sentMessages, + simulateMessage: (msg: any) => messageHandler?.(msg), + simulateDisconnect: () => disconnectHandler?.(), + }; + } + + // 构造带脚本自定义工具调用(非内置工具,走 scriptCallback)的 OpenAI SSE 响应 + function makeScriptToolCallResponse(toolId: string, toolName: string, args: string): Response { + const encoder = new TextEncoder(); + const chunks = [ + `data: {"choices":[{"delta":{"tool_calls":[{"id":"${toolId}","function":{"name":"${toolName}","arguments":""}}]}}]}\n\n`, + `data: {"choices":[{"delta":{"tool_calls":[{"function":{"arguments":${JSON.stringify(args)}}}]}, "finish_reason":"tool_calls"}]}\n\n`, + `data: {"usage":{"prompt_tokens":10,"completion_tokens":5}}\n\n`, + ]; + let i = 0; + return { + ok: true, + status: 200, + body: { + getReader() { + return { + async read() { + if (i < chunks.length) return { done: false, value: encoder.encode(chunks[i++]) }; + return { done: true, value: undefined }; + }, + cancel: async () => {}, + }; + }, + }, + } as unknown as Response; + } + + function setupConversation(mockRepo: any) { + mockRepo.listConversations.mockResolvedValue([ + { id: "conv-script", title: "Chat", modelId: "test-openai", createtime: Date.now(), updatetime: Date.now() }, + ]); + mockRepo.getMessages.mockResolvedValue([]); + } + + it("脚本工具返回结果时,应继续工具循环并完成对话", async () => { + const { service, mockRepo } = createTestService(); + setupConversation(mockRepo); + const { sender, sentMessages, simulateMessage } = createMockSender(); + + fetchSpy + .mockResolvedValueOnce(makeScriptToolCallResponse("call-success", "my_tool", "{}")) + .mockResolvedValueOnce(makeTextResponse("最终回复")); + + const chatPromise = (service as any).handleConversationChat( + { + conversationId: "conv-script", + message: "使用工具", + tools: [{ name: "my_tool", description: "d", parameters: { type: "object", properties: {} } }], + }, + sender + ); + + await vi.waitFor(() => { + expect(sentMessages.some((message) => message.action === "executeTools")).toBe(true); + }); + const executeMessage = sentMessages.find((message) => message.action === "executeTools"); + simulateMessage({ + action: "toolResults", + requestId: executeMessage.requestId, + data: [{ id: "call-success", result: "ok" }], + }); + + await expect(chatPromise).resolves.toBeUndefined(); + expect(sentMessages.some((message) => message.data?.type === "done")).toBe(true); + }); + + it("非后台会话断开(触发 abort)时,等待中的脚本工具调用应立即结束,而不是永远挂起", async () => { + const { service, mockRepo } = createTestService(); + setupConversation(mockRepo); + const { sender, simulateDisconnect } = createMockSender(); + + fetchSpy.mockResolvedValueOnce(makeScriptToolCallResponse("call-1", "my_tool", "{}")); + + const chatPromise = (service as any).handleConversationChat( + { + conversationId: "conv-script", + message: "使用工具", + tools: [{ name: "my_tool", description: "d", parameters: { type: "object", properties: {} } }], + }, + sender + ); + + // 等待 SSE 响应被消费、executeTools 消息发出,进入"等待 toolResults"状态 + await new Promise((r) => setTimeout(r, 20)); + simulateDisconnect(); + + // 若脚本工具回调未结束,chatPromise 永远不会 resolve,下面的 race 会超时 + const TIMEOUT = Symbol("timeout"); + const result = await Promise.race([ + chatPromise.then(() => "done"), + new Promise((r) => setTimeout(() => r(TIMEOUT), 500)), + ]); + expect(result).toBe("done"); + }); + + it("脚本连接长时间无响应(超时)时,应主动结束该轮脚本工具调用而不是无限期等待", async () => { + vi.useFakeTimers(); + try { + const { service, mockRepo } = createTestService(); + setupConversation(mockRepo); + const { sender, sentMessages } = createMockSender(); + + fetchSpy + .mockResolvedValueOnce(makeScriptToolCallResponse("call-2", "my_tool", "{}")) + .mockResolvedValueOnce(makeTextResponse("最终回复")); + + const chatPromise = (service as any).handleConversationChat( + { + conversationId: "conv-script", + message: "使用工具", + tools: [{ name: "my_tool", description: "d", parameters: { type: "object", properties: {} } }], + }, + sender + ); + + // 推进到脚本工具调用的超时阈值(5 分钟),期间脚本从未回复 toolResults + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + await chatPromise; + + const doneEvents = sentMessages.filter((m) => m.data?.type === "done"); + expect(doneEvents).toHaveLength(1); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("同一 conversationId 的 chat/compact/clear 必须串行执行(finding 5)", () => { + function createMockSender() { + const sentMessages: any[] = []; + const mockConn = { + sendMessage: (msg: any) => sentMessages.push(msg), + onMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + const sender = { + isType: (type: any) => type === 1, + getConnect: () => mockConn, + }; + return { sender, sentMessages }; + } + + it("clearMessages 与另一个并发的 clearMessages 请求不应交叉执行(按 conversationId 排队)", async () => { + const { service, mockRepo } = createTestService(); + + const order: string[] = []; + mockRepo.saveMessages.mockImplementation(async (_id: string, _msgs: any[]) => { + order.push("start"); + // 人为延迟第一次调用,暴露"若未排队,第二次调用会在第一次完成前插入"的竞态 + await new Promise((r) => setTimeout(r, 20)); + order.push("end"); + }); + + const call1 = (service as any).handleConversation({ action: "clearMessages", conversationId: "conv-race" }); + const call2 = (service as any).handleConversation({ action: "clearMessages", conversationId: "conv-race" }); + + await Promise.all([call1, call2]); + + // 排队生效:必须是 start,end,start,end,而不是 start,start,end,end(交叉执行) + expect(order).toEqual(["start", "end", "start", "end"]); + }); + + it("不同 conversationId 之间不应互相阻塞排队", async () => { + const { service, mockRepo } = createTestService(); + + const order: string[] = []; + mockRepo.saveMessages.mockImplementation(async (id: string) => { + order.push(`start:${id}`); + await new Promise((r) => setTimeout(r, 20)); + order.push(`end:${id}`); + }); + + const call1 = (service as any).handleConversation({ action: "clearMessages", conversationId: "conv-a" }); + const call2 = (service as any).handleConversation({ action: "clearMessages", conversationId: "conv-b" }); + + await Promise.all([call1, call2]); + + // 不同会话应并发执行,两个 start 都先于任意一个 end 出现 + expect(order.indexOf("start:conv-a")).toBeLessThan(order.indexOf("end:conv-a")); + expect(order.indexOf("start:conv-b")).toBeLessThan(order.indexOf("end:conv-b")); + expect(order.slice(0, 2).sort()).toEqual(["start:conv-a", "start:conv-b"]); + }); + + it("正在进行的 chat 与随后到达的 clearMessages 不应交叉:clear 必须等 chat 落库完成", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + try { + const { service, mockRepo } = createTestService(); + mockRepo.listConversations.mockResolvedValue([ + { id: "conv-lock", title: "Chat", modelId: "test-openai", createtime: Date.now(), updatetime: Date.now() }, + ]); + mockRepo.getMessages.mockResolvedValue([]); + + const order: string[] = []; + mockRepo.appendMessage.mockImplementation(async () => { + order.push("chat-write"); + }); + mockRepo.saveMessages.mockImplementation(async () => { + order.push("clear-write"); + }); + + fetchSpy.mockImplementation(async () => { + // 模拟一次有延迟的 LLM 响应,给 clearMessages 制造"抢在 chat 落库前执行"的窗口 + await new Promise((r) => setTimeout(r, 20)); + return makeTextResponse("ok"); + }); + + const { sender } = createMockSender(); + const chatPromise = (service as any).handleConversationChat( + { conversationId: "conv-lock", message: "hi" }, + sender + ); + const clearPromise = (service as any).handleConversation({ + action: "clearMessages", + conversationId: "conv-lock", + }); + + await Promise.all([chatPromise, clearPromise]); + + // chat 的落库(appendMessage)必须先于随后到达的 clear 的落库(saveMessages)完成, + // 而不是被 clear 抢先覆盖掉正在写入的历史 + expect(order.indexOf("chat-write")).toBeLessThan(order.indexOf("clear-write")); + } finally { + fetchSpy.mockRestore(); + } + }); +}); diff --git a/src/app/service/agent/service_worker/chat_service.ts b/src/app/service/agent/service_worker/chat_service.ts index 8ce7a833b..1d117bb09 100644 --- a/src/app/service/agent/service_worker/chat_service.ts +++ b/src/app/service/agent/service_worker/chat_service.ts @@ -1,679 +1,87 @@ import type { IGetSender } from "@Packages/message/server"; -import { GetSenderType } from "@Packages/message/server"; -import type { - AgentModelConfig, - ChatRequest, - ChatStreamEvent, - Conversation, - ConversationApiRequest, - MessageContent, - ToolDefinition, -} from "@App/app/service/agent/core/types"; -import type { ScriptToolCallback, ToolExecutor } from "@App/app/service/agent/core/tool_registry"; -import type { ToolCall } from "@App/app/service/agent/core/types"; -import type { AgentChatRepo } from "@App/app/repo/agent_chat"; -import type { ToolRegistry } from "@App/app/service/agent/core/tool_registry"; -import { SessionToolRegistry } from "@App/app/service/agent/core/session_tool_registry"; -import type { SkillService } from "./skill_service"; -import type { BackgroundSessionManager, ListenerEntry, RunningConversation } from "./background_session_manager"; -import type { AgentModelService } from "./model_service"; -import type { SubAgentService } from "./sub_agent_service"; -import type { ToolLoopOrchestrator } from "./tool_loop_orchestrator"; -import type { SubAgentRunOptions } from "@App/app/service/agent/core/tools/sub_agent"; -import { buildSystemPrompt } from "@App/app/service/agent/core/system_prompt"; -import { - COMPACT_SYSTEM_PROMPT, - buildCompactUserPrompt, - extractSummary, -} from "@App/app/service/agent/core/compact_prompt"; -import { createTaskTools } from "@App/app/service/agent/core/tools/task_tools"; -import { createAskUserTool } from "@App/app/service/agent/core/tools/ask_user"; -import { createSubAgentTool } from "@App/app/service/agent/core/tools/sub_agent"; -import { createExecuteScriptTool } from "@App/app/service/agent/core/tools/execute_script"; -import { resolveSubAgentType } from "@App/app/service/agent/core/sub_agent_types"; -import { classifyErrorCode } from "./retry_utils"; -import { getTextContent } from "@App/app/service/agent/core/content_utils"; +import type { MessageConnect } from "@Packages/message/types"; import { uuidv4 } from "@App/pkg/utils/uuid"; -import type { LLMCallResult } from "./llm_client"; - -/** ChatService 需要的 execute_script 工具依赖 */ -export interface ChatServiceExecuteScriptDeps { - executeInPage: (code: string, options?: { tabId?: number }) => Promise<{ result: unknown; tabId: number }>; - executeInSandbox: (code: string) => Promise; -} - -/** ChatService 需要的 LLM 调用依赖 */ -export interface ChatServiceLLMDeps { - callLLM: ( - model: AgentModelConfig, - params: { messages: ChatRequest["messages"]; tools?: ToolDefinition[]; cache?: boolean }, - sendEvent: (event: ChatStreamEvent) => void, - signal: AbortSignal - ) => Promise; - callLLMWithToolLoop: (params: Parameters[0]) => Promise; -} - -/** handleConversationChat 参数类型 */ -type ConversationChatParams = { - conversationId: string; - message: MessageContent; - tools?: ToolDefinition[]; - maxIterations?: number; - scriptUuid?: string; - modelId?: string; - enableTools?: boolean; // 是否携带 tools,undefined 表示不覆盖 - // 用户消息已在存储中(重新生成场景),跳过保存和 LLM 上下文追加 - skipSaveUserMessage?: boolean; - // ephemeral 会话专用字段 - ephemeral?: boolean; - messages?: ChatRequest["messages"]; - system?: string; - cache?: boolean; - // compact 模式 - compact?: boolean; - compactInstruction?: string; - // 后台运行模式 - background?: boolean; -}; - -/** buildSessionToolRegistry 的返回值 */ -interface SessionRegistryResult { - sessionRegistry: SessionToolRegistry; - promptSuffix: string; - enableTools: boolean; - metaTools: Array<{ definition: ToolDefinition; executor: ToolExecutor }>; -} - -/** buildAndPersistUserMessage 的返回值 */ -interface BuildMessagesResult { - messages: ChatRequest["messages"]; -} - -export class ChatService { - constructor( - private toolRegistry: ToolRegistry, - private modelService: AgentModelService, - private skillService: SkillService, - private bgSessionManager: BackgroundSessionManager, - private subAgentService: SubAgentService, - private executeScriptDeps: ChatServiceExecuteScriptDeps, - private llmDeps: ChatServiceLLMDeps, - private chatRepo: AgentChatRepo - ) {} - - // 处理 Sandbox conversation API 请求(非流式) - async handleConversation(params: ConversationApiRequest): Promise { - switch (params.action) { - case "create": - return this.createConversation(params); - case "get": - return this.getConversation(params.id); - case "getMessages": - return this.chatRepo.getMessages(params.conversationId); - case "save": - // 对话已经在 chat 过程中持久化,这里确保元数据也保存 - return true; - case "clearMessages": - await this.chatRepo.saveMessages(params.conversationId, []); - return true; - default: - throw new Error(`Unknown conversation action: ${(params as any).action}`); - } - } - - private async createConversation(params: Extract) { - const model = await this.modelService.getModel(params.options.model); - const conv: Conversation = { - id: params.options.id || uuidv4(), - title: "New Chat", - modelId: model.id, - system: params.options.system, - skills: params.options.skills, - createtime: Date.now(), - updatetime: Date.now(), - }; - await this.chatRepo.saveConversation(conv); - return conv; - } - - private async getConversation(id: string): Promise { - const conversations = await this.chatRepo.listConversations(); - return conversations.find((c) => c.id === id) || null; - } - - // 统一的流式 conversation chat(UI 和脚本 API 共用) - async handleConversationChat(params: ConversationChatParams, sender: IGetSender) { - if (!sender.isType(GetSenderType.CONNECT)) { - throw new Error("Conversation chat requires connect mode"); - } - const msgConn = sender.getConnect()!; - - // 后台模式:非 ephemeral、非 compact 时可用 - const isBackground = params.background === true && !params.ephemeral && !params.compact; - - // 检查是否已有后台运行的同一会话 - if (isBackground && this.bgSessionManager.has(params.conversationId)) { - msgConn.sendMessage({ - action: "event", - data: { type: "error", message: "会话正在运行中" } as ChatStreamEvent, +import type { ToolCall } from "@App/app/service/agent/core/types"; +import { ChatService as BaseChatService } from "./chat_service_base"; + +export * from "./chat_service_base"; + +/** 为脚本工具连接增加批次关联,并在后台客户端离线后返回结构化错误。 */ +export class ChatService extends BaseChatService { + async handleConversationChat(params: any, sender: IGetSender) { + const original = sender.getConnect(); + if (!original) return super.handleConversationChat(params, sender); + + let disconnected = false; + let activeRequestId: string | undefined; + const inboundHandlers: Array<(message: any) => void> = []; + + const failBatch = (message: any, reason: string) => { + const toolCalls: ToolCall[] = message.data || []; + queueMicrotask(() => { + const response = { + action: "toolResults", + requestId: message.requestId, + data: toolCalls.map((toolCall) => ({ + id: toolCall.id, + result: JSON.stringify({ error: reason }), + error: true, + })), + }; + for (const handler of inboundHandlers) handler(response); }); - return; - } - - const abortController = new AbortController(); - let isDisconnected = false; - - // 后台模式:创建 RunningConversation - let rc: RunningConversation | undefined; - if (isBackground) { - rc = { - conversationId: params.conversationId, - abortController, - listeners: new Set(), - streamingState: { content: "", thinking: "", toolCalls: [] }, - askResolvers: new Map(), - tasks: [], - status: "running", - }; - this.bgSessionManager.set(params.conversationId, rc); - } - - // ask_user resolvers(后台模式挂在 rc 上,普通模式本地) - const askResolvers = rc ? rc.askResolvers : new Map void>(); - - const sendEvent = (event: ChatStreamEvent) => { - if (rc) { - // 后台模式:先更新快照,再广播到所有 listener - this.bgSessionManager.updateStreamingState(rc, event); - this.bgSessionManager.broadcastEvent(rc, event); - } else { - if (!isDisconnected) { - msgConn.sendMessage({ action: "event", data: event }); - } - } }; - if (rc) { - // 后台模式:初始 listener - const listener: ListenerEntry = { - sendEvent: (event) => { - if (!isDisconnected) { - msgConn.sendMessage({ action: "event", data: event }); + const connection: MessageConnect = { + onMessage(callback) { + inboundHandlers.push(callback as (message: any) => void); + original.onMessage((message: any) => { + if (message.action === "toolResults") { + if (!message.requestId || message.requestId !== activeRequestId) return; + activeRequestId = undefined; } - }, - }; - rc.listeners.add(listener); - - msgConn.onDisconnect(() => { - isDisconnected = true; - // 后台模式:只移除 listener,不 abort - rc!.listeners.delete(listener); - }); - } else { - msgConn.onDisconnect(() => { - isDisconnected = true; - abortController.abort(); - }); - } - - // 构建脚本工具回调:通过 MessageConnect 让 Sandbox 执行 handler - let toolResultResolve: ((results: Array<{ id: string; result: string }>) => void) | null = null; - - msgConn.onMessage((msg: any) => { - if (msg.action === "toolResults" && toolResultResolve) { - const resolve = toolResultResolve; - toolResultResolve = null; - resolve(msg.data); - } - if (msg.action === "askUserResponse" && msg.data) { - const resolver = askResolvers.get(msg.data.id); - if (resolver) { - askResolvers.delete(msg.data.id); - if (rc) rc.pendingAskUser = undefined; - resolver(msg.data.answer); + callback(message); + }); + }, + sendMessage(message: any) { + if (message.action !== "executeTools") { + if (!disconnected) original.sendMessage(message); + return; } - } - if (msg.action === "stop") { - abortController.abort(); - } - }); - - const scriptToolCallback: ScriptToolCallback = (toolCalls: ToolCall[]) => { - return new Promise((resolve) => { - toolResultResolve = resolve; - msgConn.sendMessage({ action: "executeTools", data: toolCalls }); - }); - }; - - try { - // ephemeral 模式:无状态处理,不从 repo 加载/持久化 - if (params.ephemeral) { - await this.handleEphemeralChat(params, sendEvent, abortController, scriptToolCallback); - return; - } - - // compact 模式:压缩对话历史 - if (params.compact) { - await this.handleCompactChat(params, sendEvent, abortController); - return; - } - - // 获取对话和模型 - const conv = await this.getConversation(params.conversationId); - if (!conv) { - sendEvent({ type: "error", message: "Conversation not found" }); - return; - } - // UI 传入 modelId / enableTools 时覆盖 conversation 的配置 - let needSave = false; - if (params.modelId && params.modelId !== conv.modelId) { - conv.modelId = params.modelId; - needSave = true; - } - if (params.enableTools !== undefined && params.enableTools !== conv.enableTools) { - conv.enableTools = params.enableTools; - needSave = true; - } - if (needSave) { - conv.updatetime = Date.now(); - await this.chatRepo.saveConversation(conv); - } - - const model = await this.modelService.getModel(conv.modelId); - - // 构建 session 级工具注册表 - const { sessionRegistry, promptSuffix, enableTools, metaTools } = await this.buildSessionToolRegistry({ - conv, - model, - params, - sendEvent, - abortController, - askResolvers, - }); - - // 加载历史消息 - const existingMessages = await this.chatRepo.getMessages(params.conversationId); - - // 预加载历史中已使用过的 skill 工具 - if (enableTools) { - await this.preloadSkillsFromHistory(existingMessages, metaTools); - } - - // 构建消息列表并持久化用户消息 - const { messages } = await this.buildAndPersistUserMessage({ - conv, - params, - existingMessages, - enableTools, - promptSuffix, - }); - - try { - // 使用统一的 tool calling 循环(传入 session 级工具注册表,确保并发隔离) - await this.llmDeps.callLLMWithToolLoop({ - toolRegistry: sessionRegistry, - model, - messages, - tools: enableTools ? params.tools : undefined, - maxIterations: params.maxIterations || 50, - sendEvent, - signal: abortController.signal, - scriptToolCallback: enableTools && params.tools && params.tools.length > 0 ? scriptToolCallback : null, - conversationId: params.conversationId, - skipBuiltinTools: !enableTools, - }); - // 后台模式:正常完成后延迟清理 - this.bgSessionManager.cleanupIfDone(params.conversationId); - } finally { - // sessionRegistry 超出作用域后由 GC 清理,无需手动 unregister - // 清理子代理上下文缓存 - this.subAgentService.cleanup(params.conversationId); - } - } catch (e: any) { - // 后台模式:abort 也需要清理注册表 - if (abortController.signal.aborted) { - this.bgSessionManager.cleanupIfDone(params.conversationId); - return; - } - const errorMsg = e.message || "Unknown error"; - // 持久化错误消息到 OPFS,确保刷新后仍可见 - if (params.conversationId && !params.ephemeral) { + const correlated = { ...message, requestId: uuidv4() }; + activeRequestId = correlated.requestId; + if (disconnected) { + failBatch(correlated, "Script tool client is unavailable"); + return; + } try { - await this.chatRepo.appendMessage({ - id: uuidv4(), - conversationId: params.conversationId, - role: "assistant", - content: "", - error: errorMsg, - createtime: Date.now(), - }); - } catch { - // 持久化失败不阻塞错误事件发送 + original.sendMessage(correlated); + } catch (error) { + disconnected = true; + failBatch( + correlated, + error instanceof Error && error.message ? error.message : "Script tool client is unavailable" + ); } - } - sendEvent({ type: "error", message: errorMsg, errorCode: classifyErrorCode(e) }); - this.bgSessionManager.cleanupIfDone(params.conversationId); - } - } - - /** - * ephemeral 模式处理:无状态,不读写 repo,消息历史由调用方维护。 - * 直接使用全局 toolRegistry 路由,skipBuiltinTools=true 保证 LLM 只看到 params.tools。 - */ - private async handleEphemeralChat( - params: ConversationChatParams, - sendEvent: (event: ChatStreamEvent) => void, - abortController: AbortController, - scriptToolCallback: ScriptToolCallback - ): Promise { - const model = await this.modelService.getModel(params.modelId); - - // 使用脚本传入的完整消息历史 - const messages: ChatRequest["messages"] = []; - - // 添加 system prompt(内置提示词 + 用户自定义) - const ephemeralSystem = buildSystemPrompt({ userSystem: params.system }); - messages.push({ role: "system", content: ephemeralSystem }); - - // 添加脚本端维护的消息历史(已含最新 user message) - if (params.messages) { - for (const msg of params.messages) { - messages.push({ - role: msg.role, - content: msg.content, - toolCallId: msg.toolCallId, - toolCalls: msg.toolCalls, + }, + disconnect(ignoreAlreadyDisconnected?: boolean) { + original.disconnect(ignoreAlreadyDisconnected); + }, + onDisconnect(callback) { + original.onDisconnect((isSelfDisconnected) => { + disconnected = true; + callback(isSelfDisconnected); }); - } - } - - // ephemeral 模式无 skill/task 等 session 工具,直接使用全局 toolRegistry - // (skipBuiltinTools: true 保证 LLM 只看到 params.tools,toolRegistry 仅用于 execute 路由) - await this.llmDeps.callLLMWithToolLoop({ - toolRegistry: this.toolRegistry, - model, - messages, - tools: params.tools, - maxIterations: params.maxIterations || 20, - sendEvent, - signal: abortController.signal, - scriptToolCallback: params.tools && params.tools.length > 0 ? scriptToolCallback : null, - skipBuiltinTools: true, - cache: params.cache, - }); - } - - /** - * compact 模式处理:用 LLM 对历史消息生成摘要,替换 repo 中的全量历史。 - */ - private async handleCompactChat( - params: ConversationChatParams, - sendEvent: (event: ChatStreamEvent) => void, - abortController: AbortController - ): Promise { - const conv = await this.getConversation(params.conversationId); - if (!conv) { - sendEvent({ type: "error", message: "Conversation not found" }); - return; - } - - const model = await this.modelService.getModel(params.modelId || conv.modelId); - const existingMessages = await this.chatRepo.getMessages(params.conversationId); - - if (existingMessages.filter((m) => m.role !== "system").length === 0) { - sendEvent({ type: "error", message: "No messages to compact" }); - return; - } - - // 构建摘要请求 - const summaryMessages: ChatRequest["messages"] = []; - summaryMessages.push({ role: "system", content: COMPACT_SYSTEM_PROMPT }); - - for (const msg of existingMessages) { - if (msg.role === "system") continue; - summaryMessages.push({ - role: msg.role, - content: msg.content, - toolCallId: msg.toolCallId, - toolCalls: msg.toolCalls, - }); - } - - summaryMessages.push({ role: "user", content: buildCompactUserPrompt(params.compactInstruction) }); - - // 不带 tools 调用 LLM - const result = await this.llmDeps.callLLM( - model, - { messages: summaryMessages, cache: false }, - sendEvent, - abortController.signal - ); - - const summary = extractSummary(result.content); - const originalCount = existingMessages.length; - - // 用摘要消息替换历史 - const summaryMessage = { - id: uuidv4(), - conversationId: params.conversationId, - role: "user" as const, - content: `[Conversation Summary]\n\n${summary}`, - createtime: Date.now(), + }, }; - await this.chatRepo.saveMessages(params.conversationId, [summaryMessage]); - - sendEvent({ type: "compact_done", summary, originalCount }); - sendEvent({ type: "done", usage: result.usage }); - } - - /** - * 构建 session 级工具注册表。 - * 每个 chat 请求独立一个 SessionToolRegistry(parent = 全局 toolRegistry), - * 注册 skill meta-tools、task tools、ask_user、sub_agent、execute_script。 - * session 超出作用域后由 GC 清理。 - */ - private async buildSessionToolRegistry(ctx: { - conv: Conversation; - model: AgentModelConfig; - params: ConversationChatParams; - sendEvent: (event: ChatStreamEvent) => void; - abortController: AbortController; - askResolvers: Map void>; - }): Promise { - const { conv, model, params, sendEvent, abortController, askResolvers } = ctx; - - // enableTools 默认为 true - const enableTools = conv.enableTools !== false; - - // 每个 chat 请求一个独立的 SessionToolRegistry(parent = 全局 toolRegistry) - // 会话级 meta-tools(skill / task / ask_user / sub_agent / execute_script)只注册到 session, - // 避免并发会话的闭包互相覆盖。session 超出作用域后由 GC 清理,无需手动 unregister。 - const sessionRegistry = new SessionToolRegistry(this.toolRegistry); - - // 解析 Skills(注入 prompt + 注册 meta-tools),仅在启用 tools 时执行 - let promptSuffix = ""; - let metaTools: Array<{ definition: ToolDefinition; executor: ToolExecutor }> = []; - if (enableTools) { - const resolved = this.skillService.resolveSkills(conv.skills); - promptSuffix = resolved.promptSuffix; - metaTools = resolved.metaTools; - - // 注册 skill meta-tools 到 session - for (const mt of metaTools) { - sessionRegistry.register("skill", mt.definition, mt.executor); - } - - // Task tools(从持久化加载,变更时保存并推送事件到 UI) - const initialTasks = await this.chatRepo.getTasks(params.conversationId); - const { tools: taskToolDefs } = createTaskTools({ - initialTasks, - onSave: (tasks) => this.chatRepo.saveTasks(params.conversationId, tasks), - sendEvent, - }); - for (const t of taskToolDefs) { - sessionRegistry.register("session", t.definition, t.executor); - } - - // Ask user - const askTool = createAskUserTool(sendEvent, askResolvers); - sessionRegistry.register("session", askTool.definition, askTool.executor); - - // Sub-agent - const subAgentTool = createSubAgentTool({ - runSubAgent: (options: SubAgentRunOptions) => { - const agentId = uuidv4(); - const typeConfig = resolveSubAgentType(options.type); - // 组合父信号和类型配置的超时信号 - const subSignal = AbortSignal.any([abortController.signal, AbortSignal.timeout(typeConfig.timeoutMs)]); - // 为子代理创建完全独立的工具注册表(共享全局只读 parent,session 工具独立创建) - const childRegistry = new SessionToolRegistry(this.toolRegistry); - - const subSendEvent = (evt: ChatStreamEvent) => - sendEvent({ - ...evt, - subAgent: { - agentId, - description: options.description || "Sub-agent task", - subAgentType: typeConfig.name, - }, - } as ChatStreamEvent); - - // 独立的 task 工具(子代理有自己的任务列表) - const { tools: childTaskTools } = createTaskTools({ sendEvent: subSendEvent }); - for (const t of childTaskTools) { - childRegistry.register("session", t.definition, t.executor); - } - - // 独立的 execute_script - const childExecTool = createExecuteScriptTool(this.executeScriptDeps); - childRegistry.register("session", childExecTool.definition, childExecTool.executor); - - // general 类型:独立的 skill meta-tools(load_skill / execute_skill_script / read_reference) - let skillPromptSuffix = ""; - if (typeConfig.name === "general" && conv.skills) { - const resolved = this.skillService.resolveSkills(conv.skills); - skillPromptSuffix = resolved.promptSuffix; - for (const mt of resolved.metaTools) { - childRegistry.register("skill", mt.definition, mt.executor); - } - } - - return this.subAgentService.runSubAgent({ - options: { ...options, description: options.description || "Sub-agent task" }, - agentId, - model, - parentConversationId: params.conversationId, - signal: subSignal, - toolRegistry: childRegistry, - skillPromptSuffix, - sendEvent: subSendEvent, - }); - }, - }); - sessionRegistry.register("session", subAgentTool.definition, subAgentTool.executor); - - // Execute script - const executeScriptTool = createExecuteScriptTool(this.executeScriptDeps); - sessionRegistry.register("session", executeScriptTool.definition, executeScriptTool.executor); - } - - return { sessionRegistry, promptSuffix, enableTools, metaTools }; - } - - /** - * 扫描历史消息中的 load_skill 调用,预执行以恢复动态注册的工具。 - * 只需要副作用(注册工具),不关心执行结果。 - */ - private async preloadSkillsFromHistory( - existingMessages: Awaited>, - metaTools: Array<{ definition: ToolDefinition; executor: ToolExecutor }> - ): Promise { - const loadSkillMeta = metaTools.find((mt) => mt.definition.name === "load_skill"); - if (!loadSkillMeta) return; - - const loadedSkillNames = new Set(); - for (const msg of existingMessages) { - if (msg.role === "assistant" && msg.toolCalls) { - for (const tc of msg.toolCalls) { - if (tc.name === "load_skill") { - try { - const args = JSON.parse(tc.arguments || "{}"); - if (args.skill_name) { - loadedSkillNames.add(args.skill_name); - } - } catch { - // 解析失败,跳过 - } - } - } - } - } - - // 预执行 load_skill 以注册动态工具(结果不需要,只需要副作用) - for (const skillName of loadedSkillNames) { - try { - await loadSkillMeta.executor.execute({ skill_name: skillName }); - } catch { - // 加载失败,跳过 - } - } - } - - /** - * 构建 LLM 消息列表,持久化新用户消息,并在首次对话时更新标题。 - */ - private async buildAndPersistUserMessage(ctx: { - conv: Conversation; - params: ConversationChatParams; - existingMessages: Awaited>; - enableTools: boolean; - promptSuffix: string; - }): Promise { - const { conv, params, existingMessages, enableTools, promptSuffix } = ctx; - - // 构建消息列表 - const messages: ChatRequest["messages"] = []; - - // 添加 system 消息(内置提示词 + 用户自定义 + skill prompt) - const systemContent = buildSystemPrompt({ - userSystem: conv.system, - skillSuffix: enableTools ? promptSuffix : undefined, + const wrappedSender = new Proxy(sender, { + get(target, property, receiver) { + if (property === "getConnect") return () => connection; + return Reflect.get(target, property, receiver); + }, }); - messages.push({ role: "system", content: systemContent }); - - // 添加历史消息(跳过 system) - for (const msg of existingMessages) { - if (msg.role === "system") continue; - messages.push({ - role: msg.role, - content: msg.content, - toolCallId: msg.toolCallId, - toolCalls: msg.toolCalls, - }); - } - - if (!params.skipSaveUserMessage) { - // 添加新用户消息到 LLM 上下文并持久化 - messages.push({ role: "user", content: params.message }); - await this.chatRepo.appendMessage({ - id: uuidv4(), - conversationId: params.conversationId, - role: "user", - content: params.message, - createtime: Date.now(), - }); - } - - // 更新对话标题(如果是第一条消息) - if (existingMessages.length === 0 && conv.title === "New Chat") { - const titleText = getTextContent(params.message); - conv.title = titleText.slice(0, 30) + (titleText.length > 30 ? "..." : ""); - conv.updatetime = Date.now(); - await this.chatRepo.saveConversation(conv); - } - - return { messages }; + return super.handleConversationChat(params, wrappedSender); } } diff --git a/src/app/service/agent/service_worker/chat_service_base.ts b/src/app/service/agent/service_worker/chat_service_base.ts new file mode 100644 index 000000000..e9c0c5529 --- /dev/null +++ b/src/app/service/agent/service_worker/chat_service_base.ts @@ -0,0 +1,838 @@ +import type { IGetSender } from "@Packages/message/server"; +import { GetSenderType } from "@Packages/message/server"; +import type { + AgentModelConfig, + ChatRequest, + ChatStreamEvent, + Conversation, + ConversationApiRequest, + MessageContent, + ToolDefinition, +} from "@App/app/service/agent/core/types"; +import type { ScriptToolCallback, ToolExecutor } from "@App/app/service/agent/core/tool_registry"; +import type { ToolCall } from "@App/app/service/agent/core/types"; +import type { AgentChatRepo } from "@App/app/repo/agent_chat"; +import type { AgentConfigRepo } from "@App/app/service/agent/core/agent_config"; +import { normalizeChatMaxIterations } from "@App/app/service/agent/core/agent_config"; +import type { ToolRegistry } from "@App/app/service/agent/core/tool_registry"; +import { SessionToolRegistry } from "@App/app/service/agent/core/session_tool_registry"; +import type { SkillService } from "./skill_service"; +import type { BackgroundSessionManager, ListenerEntry, RunningConversation } from "./background_session_manager"; +import type { AgentModelService } from "./model_service"; +import type { SubAgentService } from "./sub_agent_service"; +import type { ToolLoopOrchestrator } from "./tool_loop_orchestrator"; +import type { SubAgentRunOptions } from "@App/app/service/agent/core/tools/sub_agent"; +import { buildSystemPrompt } from "@App/app/service/agent/core/system_prompt"; +import { + COMPACT_SYSTEM_PROMPT, + buildCompactUserPrompt, + extractSummary, +} from "@App/app/service/agent/core/compact_prompt"; +import { createTaskTools } from "@App/app/service/agent/core/tools/task_tools"; +import { createAskUserTool } from "@App/app/service/agent/core/tools/ask_user"; +import { createSubAgentTool } from "@App/app/service/agent/core/tools/sub_agent"; +import { createExecuteScriptTool } from "@App/app/service/agent/core/tools/execute_script"; +import { resolveSubAgentType } from "@App/app/service/agent/core/sub_agent_types"; +import { classifyErrorCode } from "./retry_utils"; +import { getTextContent } from "@App/app/service/agent/core/content_utils"; +import { toLLMMessages } from "@App/app/service/agent/core/persisted_messages"; +import { uuidv4 } from "@App/pkg/utils/uuid"; +import { stackAsyncTask } from "@App/pkg/utils/async_queue"; +import { t } from "@App/locales/locales"; +import { elideUntilWithinBudget, loadAttachmentSizes } from "@App/app/service/agent/core/context_elision"; +import { getInputTokenBudget } from "@App/app/service/agent/core/model_context"; +import type { LLMCallResult } from "./llm_client"; + +/** ChatService 需要的 execute_script 工具依赖 */ +export interface ChatServiceExecuteScriptDeps { + executeInPage: (code: string, options?: { tabId?: number }) => Promise<{ result: unknown; tabId: number }>; + executeInSandbox: (code: string, signal?: AbortSignal) => Promise; +} + +/** ChatService 需要的 LLM 调用依赖 */ +export interface ChatServiceLLMDeps { + callLLM: ( + model: AgentModelConfig, + params: { messages: ChatRequest["messages"]; tools?: ToolDefinition[]; cache?: boolean }, + sendEvent: (event: ChatStreamEvent) => void, + signal: AbortSignal + ) => Promise; + callLLMWithToolLoop: (params: Parameters[0]) => Promise; +} + +/** handleConversationChat 参数类型 */ +type ConversationChatParams = { + conversationId: string; + message: MessageContent; + tools?: ToolDefinition[]; + maxIterations?: number; + scriptUuid?: string; + modelId?: string; + enableTools?: boolean; // 是否携带 tools,undefined 表示不覆盖 + // 用户消息已在存储中(重新生成场景),跳过保存和 LLM 上下文追加 + skipSaveUserMessage?: boolean; + // ephemeral 会话专用字段 + ephemeral?: boolean; + messages?: ChatRequest["messages"]; + system?: string; + cache?: boolean; + // compact 模式 + compact?: boolean; + compactInstruction?: string; + // 后台运行模式 + background?: boolean; +}; + +/** buildSessionToolRegistry 的返回值 */ +interface SessionRegistryResult { + sessionRegistry: SessionToolRegistry; + promptSuffix: string; + enableTools: boolean; + metaTools: Array<{ definition: ToolDefinition; executor: ToolExecutor }>; +} + +/** buildAndPersistUserMessage 的返回值 */ +interface BuildMessagesResult { + messages: ChatRequest["messages"]; +} + +export class ChatService { + constructor( + private toolRegistry: ToolRegistry, + private modelService: AgentModelService, + private skillService: SkillService, + private bgSessionManager: BackgroundSessionManager, + private subAgentService: SubAgentService, + private executeScriptDeps: ChatServiceExecuteScriptDeps, + private llmDeps: ChatServiceLLMDeps, + private chatRepo: AgentChatRepo, + private agentConfigRepo: AgentConfigRepo + ) {} + + // 处理 Sandbox conversation API 请求(非流式) + async handleConversation(params: ConversationApiRequest): Promise { + switch (params.action) { + case "create": + return this.createConversation(params); + case "get": + return this.getConversation(params.id); + case "getMessages": + return this.chatRepo.getMessages(params.conversationId); + case "save": + // 对话已经在 chat 过程中持久化,这里确保元数据也保存 + return true; + case "clearMessages": + // 与 chat/compact 共用同一把按 conversationId 的队列锁,避免正在进行的对话/压缩 + // 与清空操作互相覆盖对方的写入(见 finding 5) + return stackAsyncTask(this.conversationLockKey(params.conversationId), async () => { + await this.chatRepo.saveMessages(params.conversationId, []); + return true; + }); + default: + throw new Error(`Unknown conversation action: ${(params as any).action}`); + } + } + + private async createConversation(params: Extract) { + const model = await this.modelService.getModel(params.options.model); + const conv: Conversation = { + id: params.options.id || uuidv4(), + title: "New Chat", + modelId: model.id, + system: params.options.system, + skills: params.options.skills, + createtime: Date.now(), + updatetime: Date.now(), + }; + await this.chatRepo.saveConversation(conv); + return conv; + } + + private async getConversation(id: string): Promise { + const conversations = await this.chatRepo.listConversations(); + return conversations.find((c) => c.id === id) || null; + } + + // 统一的流式 conversation chat(UI 和脚本 API 共用) + // 同一 conversationId 的 chat / compact(compact 复用本方法的 params.compact 分支)都必须与 + // clearMessages 串行执行,避免并发读改写互相覆盖对方的持久化写入(见 finding 5)。 + // "会话正在运行中" 的快速拒绝在排队之前完成,避免重复的后台请求白白卡在队列里等待。 + async handleConversationChat(params: ConversationChatParams, sender: IGetSender) { + if (!sender.isType(GetSenderType.CONNECT)) { + throw new Error("Conversation chat requires connect mode"); + } + const msgConn = sender.getConnect()!; + + // 后台模式:非 ephemeral、非 compact 时可用 + const isBackground = params.background === true && !params.ephemeral && !params.compact; + + // 检查是否已有后台运行的同一会话 + if (isBackground && this.bgSessionManager.has(params.conversationId)) { + msgConn.sendMessage({ + action: "event", + data: { type: "error", message: "会话正在运行中" } as ChatStreamEvent, + }); + return; + } + + // ephemeral 不读写 chatRepo(消息历史由调用方在内存中维护),没有跨请求的持久化竞争,无需排队 + if (params.ephemeral) { + return this.handleConversationChatLocked(params, sender); + } + + return stackAsyncTask(this.conversationLockKey(params.conversationId), () => + this.handleConversationChatLocked(params, sender) + ); + } + + private conversationLockKey(conversationId: string): string { + return `agent-chat:${conversationId}`; + } + + private async handleConversationChatLocked(params: ConversationChatParams, sender: IGetSender) { + const msgConn = sender.getConnect()!; + + // 后台模式:非 ephemeral、非 compact 时可用(外层 handleConversationChat 已用同一条件做过快速拒绝检查) + const isBackground = params.background === true && !params.ephemeral && !params.compact; + + const abortController = new AbortController(); + let isDisconnected = false; + + // 后台模式:创建 RunningConversation + let rc: RunningConversation | undefined; + if (isBackground) { + rc = { + conversationId: params.conversationId, + abortController, + listeners: new Set(), + streamingState: { content: "", thinking: "", toolCalls: [] }, + askResolvers: new Map(), + tasks: [], + status: "running", + }; + this.bgSessionManager.set(params.conversationId, rc); + } + + // ask_user resolvers(后台模式挂在 rc 上,普通模式本地) + const askResolvers = rc ? rc.askResolvers : new Map void>(); + + const sendEvent = (event: ChatStreamEvent) => { + if (rc) { + // 后台模式:先更新快照,再广播到所有 listener + this.bgSessionManager.updateStreamingState(rc, event); + this.bgSessionManager.broadcastEvent(rc, event); + } else { + if (!isDisconnected) { + msgConn.sendMessage({ action: "event", data: event }); + } + } + }; + + // 循环检测(tool_call_guard)连续命中时暂停询问用户是否继续;复用 ask_user 的事件/resolver 机制, + // 5 分钟无人应答时默认"继续",避免无 UI 监听的后台会话被无限期挂起 + const askUserForGuard = (strikeCount: number): Promise => { + return new Promise((resolve) => { + if (abortController.signal.aborted) { + resolve("stop"); + return; + } + const askId = `guard_${uuidv4()}`; + const cleanup = () => abortController.signal.removeEventListener("abort", onAbort); + // settle 只负责清理与 resolve,不发送任何终态事件; + // 终态事件(resolved / expired)由每个触发路径各自发送且只发一次,避免重复广播 + const settle = (answer: string) => { + clearTimeout(timer); + askResolvers.delete(askId); + cleanup(); + resolve(answer); + }; + const onAbort = () => { + sendEvent({ type: "ask_user_expired", id: askId }); + settle("stop"); + }; + const timer = setTimeout( + () => { + sendEvent({ type: "ask_user_expired", id: askId }); + settle("continue"); + }, + 5 * 60 * 1000 + ); + sendEvent({ + type: "ask_user", + id: askId, + question: t("agent:chat_guard_question", { count: strikeCount }), + options: [t("agent:chat_guard_continue"), t("agent:chat_guard_stop")], + optionValues: ["continue", "stop"], + multiple: false, + allowCustom: false, + }); + abortController.signal.addEventListener("abort", onAbort, { once: true }); + askResolvers.set(askId, (answer: string) => { + sendEvent({ type: "ask_user_resolved", id: askId }); + settle(answer); + }); + }); + }; + + // 等待中的脚本工具调用:MessageConnect 断开或 abortController 触发时, + // 必须主动结束这个 pending promise —— 只有这个连接对应的 Sandbox 能回复 toolResults, + // 连接一旦断开该调用永远不会有结果,不结束会让整条 tool loop 挂起。 + let pendingScriptCall: { + toolCalls: ToolCall[]; + settle: (results: Array<{ id: string; result: string; error?: boolean }>) => void; + } | null = null; + + const scriptToolAbortedResults = (message: string) => + pendingScriptCall!.toolCalls.map((tc) => ({ + id: tc.id, + result: JSON.stringify({ error: message }), + error: true, + })); + + const settlePendingScriptCall = (message: string) => { + if (!pendingScriptCall) return; + pendingScriptCall.settle(scriptToolAbortedResults(message)); + }; + + if (rc) { + // 后台模式:初始 listener + const listener: ListenerEntry = { + sendEvent: (event) => { + if (!isDisconnected) { + msgConn.sendMessage({ action: "event", data: event }); + } + }, + }; + rc.listeners.add(listener); + + msgConn.onDisconnect(() => { + isDisconnected = true; + // 后台模式:只移除 listener,不 abort 整条会话; + // 但这条连接对应的脚本工具调用必须结束,否则永远等不到 toolResults + rc!.listeners.delete(listener); + settlePendingScriptCall("Script connection disconnected"); + }); + } else { + msgConn.onDisconnect(() => { + isDisconnected = true; + abortController.abort(); + }); + } + + // abort(stop / 非后台断开)时结束等待中的脚本工具调用,避免循环卡死 + abortController.signal.addEventListener("abort", () => { + settlePendingScriptCall("Tool execution aborted"); + }); + + msgConn.onMessage((msg: any) => { + if (msg.action === "toolResults" && pendingScriptCall) { + const { settle } = pendingScriptCall; + settle(msg.data); + } + if (msg.action === "askUserResponse" && msg.data) { + const resolver = askResolvers.get(msg.data.id); + if (resolver) { + askResolvers.delete(msg.data.id); + if (rc) rc.pendingAskUser = undefined; + resolver(msg.data.answer); + } + } + if (msg.action === "stop") { + if (rc) { + this.bgSessionManager.stop(params.conversationId, rc); + } else { + abortController.abort(); + } + } + }); + + // 脚本工具单次调用的最长等待时间:Sandbox 长时间无响应(如脚本卡死)时, + // 主动结束这轮 tool call 而不是无限期挂起整条对话 + const SCRIPT_TOOL_TIMEOUT_MS = 5 * 60 * 1000; + + const scriptToolCallback: ScriptToolCallback = (toolCalls: ToolCall[]) => { + return new Promise((resolve) => { + const settle = (results: Array<{ id: string; result: string; error?: boolean }>) => { + if (pendingScriptCall?.settle !== settle) return; + pendingScriptCall = null; + clearTimeout(timer); + resolve(results); + }; + const timer = setTimeout(() => { + settle( + toolCalls.map((tc) => ({ + id: tc.id, + result: JSON.stringify({ error: "Tool execution timed out" }), + error: true, + })) + ); + }, SCRIPT_TOOL_TIMEOUT_MS); + + pendingScriptCall = { toolCalls, settle }; + msgConn.sendMessage({ action: "executeTools", data: toolCalls }); + + if (abortController.signal.aborted) { + settle(scriptToolAbortedResults("Tool execution aborted")); + } + }); + }; + + try { + // ephemeral 模式:无状态处理,不从 repo 加载/持久化 + if (params.ephemeral) { + await this.handleEphemeralChat(params, sendEvent, abortController, scriptToolCallback); + return; + } + + // compact 模式:压缩对话历史 + if (params.compact) { + await this.handleCompactChat(params, sendEvent, abortController); + return; + } + + // 获取对话和模型 + const conv = await this.getConversation(params.conversationId); + if (!conv) { + sendEvent({ type: "error", message: "Conversation not found" }); + return; + } + + // UI 传入 modelId / enableTools 时覆盖 conversation 的配置 + let needSave = false; + if (params.modelId && params.modelId !== conv.modelId) { + conv.modelId = params.modelId; + needSave = true; + } + if (params.enableTools !== undefined && params.enableTools !== conv.enableTools) { + conv.enableTools = params.enableTools; + needSave = true; + } + if (needSave) { + conv.updatetime = Date.now(); + await this.chatRepo.saveConversation(conv); + } + + const model = await this.modelService.getModel(conv.modelId); + // UI 未显式传入 maxIterations 时,使用用户在设置页配置的对话最大循环次数 + const agentConfig = await this.agentConfigRepo.getConfig(); + + // 构建 session 级工具注册表 + const { sessionRegistry, promptSuffix, enableTools, metaTools } = await this.buildSessionToolRegistry({ + conv, + model, + params, + sendEvent, + abortController, + askResolvers, + }); + + // 加载历史消息 + const existingMessages = await this.chatRepo.getMessages(params.conversationId); + + // 预加载历史中已使用过的 skill 工具 + if (enableTools) { + await this.preloadSkillsFromHistory(existingMessages, metaTools, abortController.signal); + } + + // 构建消息列表并持久化用户消息 + const { messages } = await this.buildAndPersistUserMessage({ + conv, + params, + existingMessages, + enableTools, + promptSuffix, + }); + + try { + // 使用统一的 tool calling 循环(传入 session 级工具注册表,确保并发隔离) + await this.llmDeps.callLLMWithToolLoop({ + toolRegistry: sessionRegistry, + model, + messages, + tools: enableTools ? params.tools : undefined, + // ?? 而非 || :显式传入 0 不应被当作"未传入"而回退到配置值; + // 无论来自配置还是直接传参,最终值都经 normalizeChatMaxIterations 兜底截断, + // 避免非法值(如负数)导致循环立即失败或无限运行 + maxIterations: normalizeChatMaxIterations(params.maxIterations ?? agentConfig.chatMaxIterations), + sendEvent, + signal: abortController.signal, + scriptToolCallback: enableTools && params.tools && params.tools.length > 0 ? scriptToolCallback : null, + conversationId: params.conversationId, + rehydratedHistory: true, + skipBuiltinTools: !enableTools, + askUserForGuard: params.scriptUuid ? undefined : askUserForGuard, + }); + // callLLMWithToolLoop 在 signal.aborted 时是 return(正常 resolve)而非 throw, + // 因此 abort 落定也会走到这里;必须先收敛 cancelling 为终态,而不是直接当作正常完成清理 + if (rc && abortController.signal.aborted) { + this.bgSessionManager.finalizeCancelled(params.conversationId, rc); + } else { + // 后台模式:正常完成后延迟清理 + this.bgSessionManager.cleanupIfDone(params.conversationId); + } + } finally { + // sessionRegistry 超出作用域后由 GC 清理,无需手动 unregister + // 清理子代理上下文缓存 + this.subAgentService.cleanup(params.conversationId); + } + } catch (e: any) { + // 后台模式:abort 后必须等待本次执行 promise 真正落定,才能把 cancelling 收敛为终态, + // 否则 stop() 造成的 cancelling 占位会一直阻塞同 ID 的新会话(见 finalizeCancelled) + if (abortController.signal.aborted) { + if (rc) { + this.bgSessionManager.finalizeCancelled(params.conversationId, rc); + } else { + this.bgSessionManager.cleanupIfDone(params.conversationId); + } + return; + } + const errorMsg = e.message || "Unknown error"; + const errorCode = classifyErrorCode(e); + // 持久化错误消息到 OPFS,确保刷新后仍可见 + if (params.conversationId && !params.ephemeral) { + try { + await this.chatRepo.appendMessage({ + id: uuidv4(), + conversationId: params.conversationId, + role: "assistant", + content: "", + error: errorMsg, + errorCode, + usage: e.usage, + durationMs: e.durationMs, + createtime: Date.now(), + }); + } catch { + // 持久化失败不阻塞错误事件发送 + } + } + sendEvent({ type: "error", message: errorMsg, errorCode, usage: e.usage, durationMs: e.durationMs }); + this.bgSessionManager.cleanupIfDone(params.conversationId); + } + } + + /** + * ephemeral 模式处理:无状态,不读写 repo,消息历史由调用方维护。 + * 直接使用全局 toolRegistry 路由,skipBuiltinTools=true 保证 LLM 只看到 params.tools。 + */ + private async handleEphemeralChat( + params: ConversationChatParams, + sendEvent: (event: ChatStreamEvent) => void, + abortController: AbortController, + scriptToolCallback: ScriptToolCallback + ): Promise { + const model = await this.modelService.getModel(params.modelId); + + // 使用脚本传入的完整消息历史 + const messages: ChatRequest["messages"] = []; + + // 添加 system prompt(内置提示词 + 用户自定义) + const ephemeralSystem = buildSystemPrompt({ userSystem: params.system }); + messages.push({ role: "system", content: ephemeralSystem }); + + // 添加脚本端维护的消息历史(已含最新 user message) + if (params.messages) { + for (const msg of params.messages) { + messages.push(...toLLMMessages([msg])); + } + } + + // ephemeral 模式无 skill/task 等 session 工具,直接使用全局 toolRegistry + // (skipBuiltinTools: true 保证 LLM 只看到 params.tools,toolRegistry 仅用于 execute 路由) + await this.llmDeps.callLLMWithToolLoop({ + toolRegistry: this.toolRegistry, + model, + messages, + tools: params.tools, + maxIterations: normalizeChatMaxIterations(params.maxIterations ?? 20), + sendEvent, + signal: abortController.signal, + scriptToolCallback: params.tools && params.tools.length > 0 ? scriptToolCallback : null, + skipBuiltinTools: true, + cache: params.cache, + }); + } + + /** + * compact 模式处理:用 LLM 对历史消息生成摘要,替换 repo 中的全量历史。 + */ + private async handleCompactChat( + params: ConversationChatParams, + sendEvent: (event: ChatStreamEvent) => void, + abortController: AbortController + ): Promise { + const conv = await this.getConversation(params.conversationId); + if (!conv) { + sendEvent({ type: "error", message: "Conversation not found" }); + return; + } + + const model = await this.modelService.getModel(params.modelId || conv.modelId); + const existingMessages = await this.chatRepo.getMessages(params.conversationId); + const historyMessages = toLLMMessages(existingMessages).filter((msg) => msg.role !== "system"); + + if (historyMessages.length === 0) { + sendEvent({ type: "error", message: "No messages to compact" }); + return; + } + + // 构建摘要请求 + const summaryMessages: ChatRequest["messages"] = []; + summaryMessages.push({ role: "system", content: COMPACT_SYSTEM_PROMPT }); + + summaryMessages.push(...historyMessages); + summaryMessages.push({ role: "user", content: buildCompactUserPrompt(params.compactInstruction) }); + + const attachmentSizes = await loadAttachmentSizes(summaryMessages, (id) => this.chatRepo.getAttachment(id)); + const inputBudget = getInputTokenBudget(model); + const effectiveWindow = Math.max(1, Math.floor(inputBudget / 0.9)); + if (!elideUntilWithinBudget(summaryMessages, effectiveWindow, undefined, 0.9, attachmentSizes, model)) { + sendEvent({ + type: "error", + message: "Conversation history is too large to compact", + errorCode: "context_too_large", + }); + return; + } + + // 不带 tools 调用 LLM + const result = await this.llmDeps.callLLM( + model, + { messages: summaryMessages, cache: false }, + sendEvent, + abortController.signal + ); + + // LLM 调用期间可能已被 stop:落库/广播终态事件前必须重新检查, + // 否则 cancelled 之后仍可能持久化摘要并发出 compact_done/done + if (abortController.signal.aborted) return; + + const summary = extractSummary(result.content); + const originalCount = existingMessages.length; + + // 用摘要消息替换历史 + const summaryMessage = { + id: uuidv4(), + conversationId: params.conversationId, + role: "user" as const, + content: `[Conversation Summary]\n\n${summary}`, + createtime: Date.now(), + }; + // 传入 signal:写入落定前若已 abort,则放弃这次整份覆写而不提交(见 finding 4) + await this.chatRepo.saveMessages(params.conversationId, [summaryMessage], abortController.signal); + + // 持久化期间也可能已被 stop:终态事件只能在确认未取消时发送 + if (abortController.signal.aborted) return; + + sendEvent({ type: "compact_done", summary, originalCount }); + sendEvent({ type: "done", usage: result.usage }); + } + + /** + * 构建 session 级工具注册表。 + * 每个 chat 请求独立一个 SessionToolRegistry(parent = 全局 toolRegistry), + * 注册 skill meta-tools、task tools、ask_user、sub_agent、execute_script。 + * session 超出作用域后由 GC 清理。 + */ + private async buildSessionToolRegistry(ctx: { + conv: Conversation; + model: AgentModelConfig; + params: ConversationChatParams; + sendEvent: (event: ChatStreamEvent) => void; + abortController: AbortController; + askResolvers: Map void>; + }): Promise { + const { conv, model, params, sendEvent, abortController, askResolvers } = ctx; + + // enableTools 默认为 true + const enableTools = conv.enableTools !== false; + + // 每个 chat 请求一个独立的 SessionToolRegistry(parent = 全局 toolRegistry) + // 会话级 meta-tools(skill / task / ask_user / sub_agent / execute_script)只注册到 session, + // 避免并发会话的闭包互相覆盖。session 超出作用域后由 GC 清理,无需手动 unregister。 + const sessionRegistry = new SessionToolRegistry(this.toolRegistry); + + // 解析 Skills(注入 prompt + 注册 meta-tools),仅在启用 tools 时执行 + let promptSuffix = ""; + let metaTools: Array<{ definition: ToolDefinition; executor: ToolExecutor }> = []; + if (enableTools) { + const resolved = this.skillService.resolveSkills(conv.skills); + promptSuffix = resolved.promptSuffix; + metaTools = resolved.metaTools; + + // 注册 skill meta-tools 到 session + for (const mt of metaTools) { + sessionRegistry.register("skill", mt.definition, mt.executor); + } + + // Task tools(从持久化加载,变更时保存并推送事件到 UI) + const initialTasks = await this.chatRepo.getTasks(params.conversationId); + const { tools: taskToolDefs } = createTaskTools({ + initialTasks, + onSave: (tasks) => this.chatRepo.saveTasks(params.conversationId, tasks), + sendEvent, + }); + for (const t of taskToolDefs) { + sessionRegistry.register("session", t.definition, t.executor); + } + + // Ask user + if (!params.scriptUuid) { + const askTool = createAskUserTool(sendEvent, askResolvers, abortController.signal); + sessionRegistry.register("session", askTool.definition, askTool.executor); + } + + // Sub-agent + const subAgentTool = createSubAgentTool({ + runSubAgent: (options: SubAgentRunOptions) => { + const agentId = uuidv4(); + const typeConfig = resolveSubAgentType(options.type); + // 组合父信号和类型配置的超时信号 + const subSignal = AbortSignal.any([abortController.signal, AbortSignal.timeout(typeConfig.timeoutMs)]); + + // 为子代理创建完全独立的工具注册表(共享全局只读 parent,session 工具独立创建) + const childRegistry = new SessionToolRegistry(this.toolRegistry); + + const subSendEvent = (evt: ChatStreamEvent) => + sendEvent({ + ...evt, + subAgent: { + agentId, + description: options.description || "Sub-agent task", + subAgentType: typeConfig.name, + }, + } as ChatStreamEvent); + + // 独立的 task 工具(子代理有自己的任务列表) + const { tools: childTaskTools } = createTaskTools({ sendEvent: subSendEvent }); + for (const t of childTaskTools) { + childRegistry.register("session", t.definition, t.executor); + } + + // 独立的 execute_script + const childExecTool = createExecuteScriptTool(this.executeScriptDeps); + childRegistry.register("session", childExecTool.definition, childExecTool.executor); + + // general 类型:独立的 skill meta-tools(load_skill / execute_skill_script / read_reference) + let skillPromptSuffix = ""; + if (typeConfig.name === "general" && conv.skills) { + const resolved = this.skillService.resolveSkills(conv.skills); + skillPromptSuffix = resolved.promptSuffix; + for (const mt of resolved.metaTools) { + childRegistry.register("skill", mt.definition, mt.executor); + } + } + + return this.subAgentService.runSubAgent({ + options: { ...options, description: options.description || "Sub-agent task" }, + agentId, + model, + parentConversationId: params.conversationId, + signal: subSignal, + toolRegistry: childRegistry, + skillPromptSuffix, + sendEvent: subSendEvent, + }); + }, + }); + sessionRegistry.register("session", subAgentTool.definition, subAgentTool.executor); + + // Execute script + const executeScriptTool = createExecuteScriptTool(this.executeScriptDeps); + sessionRegistry.register("session", executeScriptTool.definition, executeScriptTool.executor); + } + + return { sessionRegistry, promptSuffix, enableTools, metaTools }; + } + + /** + * 扫描历史消息中的 load_skill 调用,预执行以恢复动态注册的工具。 + * 只需要副作用(注册工具),不关心执行结果。 + */ + private async preloadSkillsFromHistory( + existingMessages: Awaited>, + metaTools: Array<{ definition: ToolDefinition; executor: ToolExecutor }>, + signal?: AbortSignal + ): Promise { + const loadSkillMeta = metaTools.find((mt) => mt.definition.name === "load_skill"); + if (!loadSkillMeta) return; + + const loadedSkillNames = new Set(); + for (const msg of existingMessages) { + if (msg.role === "assistant" && msg.toolCalls) { + for (const tc of msg.toolCalls) { + if (tc.name === "load_skill") { + try { + const args = JSON.parse(tc.arguments || "{}"); + if (args.skill_name) { + loadedSkillNames.add(args.skill_name); + } + } catch { + // 解析失败,跳过 + } + } + } + } + } + + // 预执行 load_skill 以注册动态工具(结果不需要,只需要副作用) + for (const skillName of loadedSkillNames) { + if (signal?.aborted) break; + try { + await loadSkillMeta.executor.execute({ skill_name: skillName }, signal); + } catch { + // 加载失败,跳过 + } + } + } + + /** + * 构建 LLM 消息列表,持久化新用户消息,并在首次对话时更新标题。 + */ + private async buildAndPersistUserMessage(ctx: { + conv: Conversation; + params: ConversationChatParams; + existingMessages: Awaited>; + enableTools: boolean; + promptSuffix: string; + }): Promise { + const { conv, params, existingMessages, enableTools, promptSuffix } = ctx; + + // 构建消息列表 + const messages: ChatRequest["messages"] = []; + + // 添加 system 消息(内置提示词 + 用户自定义 + skill prompt) + const systemContent = buildSystemPrompt({ + userSystem: conv.system, + skillSuffix: enableTools ? promptSuffix : undefined, + }); + messages.push({ role: "system", content: systemContent }); + + // 添加历史消息(跳过 system;跳过错误占位消息 —— 如超过 max_iterations 时持久化的空 content + // assistant 消息,仅用于 UI 展示"继续对话"操作,重放给 LLM 会产生空 content 的无意义消息, + // 部分 provider(如 Anthropic)甚至会因空 content 拒绝请求) + messages.push(...toLLMMessages(existingMessages).filter((msg) => msg.role !== "system")); + + if (!params.skipSaveUserMessage) { + // 添加新用户消息到 LLM 上下文并持久化 + messages.push({ role: "user", content: params.message }); + await this.chatRepo.appendMessage({ + id: uuidv4(), + conversationId: params.conversationId, + role: "user", + content: params.message, + createtime: Date.now(), + }); + } + + // 更新对话标题(如果是第一条消息) + if (existingMessages.length === 0 && conv.title === "New Chat") { + const titleText = getTextContent(params.message); + conv.title = titleText.slice(0, 30) + (titleText.length > 30 ? "..." : ""); + conv.updatetime = Date.now(); + await this.chatRepo.saveConversation(conv); + } + + return { messages }; + } +} diff --git a/src/app/service/agent/service_worker/compact_service.test.ts b/src/app/service/agent/service_worker/compact_service.test.ts new file mode 100644 index 000000000..9fbd36671 --- /dev/null +++ b/src/app/service/agent/service_worker/compact_service.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from "vitest"; +import { CompactService } from "./compact_service"; +import type { AgentModelConfig, ChatRequest } from "@App/app/service/agent/core/types"; +import { COMPACT_SYSTEM_PROMPT, buildCompactUserPrompt } from "@App/app/service/agent/core/compact_prompt"; +import { estimateRequestTokens } from "@App/app/service/agent/core/context_elision"; +import { getContextWindow, getInputTokenBudget } from "@App/app/service/agent/core/model_context"; + +const MODEL: AgentModelConfig = { + id: "compact-model", + name: "Compact", + provider: "openai", + apiBaseUrl: "", + apiKey: "", + model: "gpt-4o", + contextWindow: 10_000, + maxTokens: 2_000, +}; + +// 二分查找最小长度而非逐字符递增:estimateRequestTokens 引入字节→token 折算后, +// 命中目标区间所需的字符数随之变大,逐字符线性扫描(每次都要 O(len) 的 JSON.stringify) +// 会在字符数翻倍后耗时成倍增长,曾在 CI 上导致该用例超时。 +function makeCurrentMessages(): ChatRequest["messages"] { + const buildMessages = (len: number): ChatRequest["messages"] => [{ role: "user", content: "x".repeat(len) }]; + const estimateFor = (len: number) => { + const summaryMessages: ChatRequest["messages"] = [ + { role: "system", content: COMPACT_SYSTEM_PROMPT }, + ...buildMessages(len), + { role: "user", content: buildCompactUserPrompt() }, + ]; + return estimateRequestTokens(summaryMessages, undefined, undefined, MODEL); + }; + + const budget = getInputTokenBudget(MODEL); + const ceiling = getContextWindow(MODEL) * 0.9; + + let low = 1; + let high = 20_000; + while (low < high) { + const mid = Math.floor((low + high) / 2); + if (estimateFor(mid) > budget) high = mid; + else low = mid + 1; + } + + const estimate = estimateFor(low); + if (estimate > budget && estimate < ceiling) return buildMessages(low); + throw new Error("未能构造出位于输入预算与 90% 预检之间的 compact 测试样例"); +} + +describe("CompactService 自动压缩", () => { + it("自动压缩应返回摘要请求的 token 用量", async () => { + const usage = { inputTokens: 120, outputTokens: 30, cacheCreationInputTokens: 10, cacheReadInputTokens: 5 }; + const modelService = {} as any; + const orchestrator = { callLLM: vi.fn().mockResolvedValue({ content: "摘要", usage }) }; + const chatRepo = { + getAttachment: vi.fn().mockResolvedValue(null), + saveMessages: vi.fn().mockResolvedValue(undefined), + } as any; + const service = new CompactService(modelService, orchestrator, chatRepo); + + await expect( + service.autoCompact( + "conv-1", + MODEL, + [{ role: "user", content: "需要摘要的内容" }], + vi.fn(), + new AbortController().signal + ) + ).resolves.toEqual(usage); + }); + + it("摘要请求超过输出保留预算时应返回 context_too_large", async () => { + const modelService = {} as any; + const orchestrator = { callLLM: vi.fn() }; + const chatRepo = { + getAttachment: vi.fn().mockResolvedValue(null), + saveMessages: vi.fn().mockResolvedValue(undefined), + } as any; + const service = new CompactService(modelService, orchestrator, chatRepo); + const sendEvent = vi.fn(); + const signal = new AbortController().signal; + const currentMessages = makeCurrentMessages(); + + await expect(service.autoCompact("conv-1", MODEL, currentMessages, sendEvent, signal)).rejects.toMatchObject({ + errorCode: "context_too_large", + }); + + expect(orchestrator.callLLM).not.toHaveBeenCalled(); + expect(chatRepo.saveMessages).not.toHaveBeenCalled(); + }); + + it("摘要内容超过摘要模型预算时应在调用 provider 前返回 context_too_large", async () => { + const modelService = { + getSummaryModel: vi.fn().mockResolvedValue(MODEL), + } as any; + const orchestrator = { callLLM: vi.fn().mockResolvedValue({ content: "ok" }) }; + const chatRepo = { + getAttachment: vi.fn().mockResolvedValue(null), + saveMessages: vi.fn().mockResolvedValue(undefined), + } as any; + const service = new CompactService(modelService, orchestrator, chatRepo); + const hugeContent = "x".repeat(20_000); + + await expect(service.summarizeContent(hugeContent, "extract")).rejects.toMatchObject({ + errorCode: "context_too_large", + }); + + expect(orchestrator.callLLM).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/service/agent/service_worker/compact_service.ts b/src/app/service/agent/service_worker/compact_service.ts index b09a47362..69b70491d 100644 --- a/src/app/service/agent/service_worker/compact_service.ts +++ b/src/app/service/agent/service_worker/compact_service.ts @@ -6,6 +6,7 @@ import type { ToolCall, ContentBlock, ToolDefinition, + TokenUsage, } from "@App/app/service/agent/core/types"; import { COMPACT_SYSTEM_PROMPT, @@ -14,6 +15,13 @@ import { } from "@App/app/service/agent/core/compact_prompt"; import { uuidv4 } from "@App/pkg/utils/uuid"; import type { AgentModelService } from "./model_service"; +import { + elideUntilWithinBudget, + estimateRequestTokens, + loadAttachmentSizes, +} from "@App/app/service/agent/core/context_elision"; +import { getInputTokenBudget } from "@App/app/service/agent/core/model_context"; +import { throwIfAborted } from "@App/app/service/agent/core/abort_utils"; /** LLM 调用结果(与 AgentService.callLLM 返回值一致) */ interface CompactLLMResult { @@ -53,7 +61,9 @@ export class CompactService { currentMessages: ChatRequest["messages"], sendEvent: (event: ChatStreamEvent) => void, signal: AbortSignal - ): Promise { + ): Promise { + throwIfAborted(signal); + // 构建摘要请求(用 currentMessages 而非从 repo 加载,因为可能有未持久化的 tool 消息) const summaryMessages: ChatRequest["messages"] = []; summaryMessages.push({ role: "system", content: COMPACT_SYSTEM_PROMPT }); @@ -64,6 +74,15 @@ export class CompactService { } summaryMessages.push({ role: "user", content: buildCompactUserPrompt() }); + const attachmentSizes = await loadAttachmentSizes(summaryMessages, (id) => this.chatRepo.getAttachment(id)); + const inputBudget = getInputTokenBudget(model); + const effectiveWindow = Math.max(1, Math.floor(inputBudget / 0.9)); + if (!elideUntilWithinBudget(summaryMessages, effectiveWindow, undefined, 0.9, attachmentSizes, model)) { + throw Object.assign(new Error("Conversation history is too large to compact"), { + errorCode: "context_too_large", + }); + } + // 调用 LLM 获取摘要(不带 tools,不发流式事件给 UI) const noopSendEvent = () => {}; const result = await this.orchestrator.callLLM( @@ -73,15 +92,15 @@ export class CompactService { signal ); - const summary = extractSummary(result.content); + // LLM 调用期间可能已被 stop:落地前必须重新检查,避免取消之后仍持久化/广播 compact_done + throwIfAborted(signal); - // 替换 currentMessages(保留 system,替换其余为摘要) - const systemMsg = currentMessages.find((m) => m.role === "system"); - currentMessages.length = 0; - if (systemMsg) currentMessages.push(systemMsg); - currentMessages.push({ role: "user", content: `[Conversation Summary]\n\n${summary}` }); + const summary = extractSummary(result.content); - // 持久化 + // 持久化:先写盘、成功后才允许覆写内存中的 currentMessages。 + // OPFS 的 createWritable() 是事务性的,signal 在 close() 落定前 abort 时会放弃这次 + // 整份覆写而不提交(见 opfs_repo.ts writeJsonFile);只有 saveMessages 真正成功后, + // 再让内存态 currentMessages 反映同一份摘要,避免"写盘失败但内存已被摘要顶替"的不一致(见 finding 4) const summaryMessage = { id: uuidv4(), conversationId, @@ -89,14 +108,27 @@ export class CompactService { content: `[Conversation Summary]\n\n${summary}`, createtime: Date.now(), }; - await this.chatRepo.saveMessages(conversationId, [summaryMessage]); + await this.chatRepo.saveMessages(conversationId, [summaryMessage], signal); + + // 落盘之后也可能已被 Stop:写入已提交(无法撤销),但内存态覆盖和 compact_done 广播 + // 必须让位给取消——不能在 Stop 之后仍报告自动压缩"成功"(见 finding 3) + throwIfAborted(signal); + + // 替换 currentMessages(保留 system,替换其余为摘要)——只有走到这里才说明落盘已提交 + const systemMsg = currentMessages.find((m) => m.role === "system"); + currentMessages.length = 0; + if (systemMsg) currentMessages.push(systemMsg); + currentMessages.push({ role: "user", content: `[Conversation Summary]\n\n${summary}` }); // 通知 UI sendEvent({ type: "compact_done", summary, originalCount: -1 }); + return result.usage; } /** 使用 summary 模型对任意内容做提取/总结(供 tab 工具使用) */ - async summarizeContent(content: string, prompt: string): Promise { + async summarizeContent(content: string, prompt: string, signal?: AbortSignal): Promise { + throwIfAborted(signal); + const model = await this.modelService.getSummaryModel(); const messages: ChatRequest["messages"] = [ @@ -111,17 +143,33 @@ export class CompactService { }, ]; + const inputBudget = getInputTokenBudget(model); + const estimatedInputTokens = estimateRequestTokens(messages, undefined, undefined, model); + if (estimatedInputTokens > inputBudget) { + throw Object.assign(new Error("Summarization content exceeds the summary model context window"), { + errorCode: "context_too_large", + usage: { + inputTokens: estimatedInputTokens, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + }, + }); + } + const noopSendEvent = () => {}; - const controller = new AbortController(); try { const result = await this.orchestrator.callLLM( model, { messages, cache: false }, noopSendEvent, - controller.signal + signal ?? new AbortController().signal ); return result.content; } catch (e: any) { + if (e?.errorCode || e?.message === "Aborted") { + throw e; + } throw new Error(`Summarization failed: ${e.message}`); } } diff --git a/src/app/service/agent/service_worker/llm.test.ts b/src/app/service/agent/service_worker/llm.test.ts index ff480bf9b..6339a16d8 100644 --- a/src/app/service/agent/service_worker/llm.test.ts +++ b/src/app/service/agent/service_worker/llm.test.ts @@ -69,7 +69,7 @@ describe("callLLM 流式响应解析", () => { fetchSpy.mockResolvedValueOnce( makeSSEResponse([ `data: {"choices":[{"delta":{"content":"你好"}}]}\n\n`, - `data: {"choices":[{"delta":{"content":"世界"}}]}\n\n`, + `data: {"choices":[{"delta":{"content":"世界"},"finish_reason":"stop"}]}\n\n`, `data: {"usage":{"prompt_tokens":10,"completion_tokens":5}}\n\n`, ]) ); @@ -195,7 +195,7 @@ describe("callLLM 流式响应解析", () => { } as unknown as Response); fetchSpy.mockResolvedValueOnce( makeSSEResponse([ - `data: {"choices":[{"delta":{"content":"重试成功"}}]}\n\n`, + `data: {"choices":[{"delta":{"content":"重试成功"},"finish_reason":"stop"}]}\n\n`, `data: {"usage":{"prompt_tokens":5,"completion_tokens":2}}\n\n`, ]) ); @@ -241,7 +241,7 @@ describe("callLLM 流式响应解析", () => { // 第二次成功 fetchSpy.mockResolvedValueOnce( makeSSEResponse([ - `data: {"choices":[{"delta":{"content":"恢复了"}}]}\n\n`, + `data: {"choices":[{"delta":{"content":"恢复了"},"finish_reason":"stop"}]}\n\n`, `data: {"usage":{"prompt_tokens":10,"completion_tokens":3}}\n\n`, ]) ); @@ -350,18 +350,38 @@ describe("callLLMWithToolLoop 工具调用循环", () => { function makeToolCallResponse(toolCalls: Array<{ id: string; name: string; arguments: string }>): Response { const chunks: string[] = []; - for (const tc of toolCalls) { + toolCalls.forEach((tc, i) => { chunks.push( `data: {"choices":[{"delta":{"tool_calls":[{"id":"${tc.id}","function":{"name":"${tc.name}","arguments":""}}]}}]}\n\n` ); + const isLast = i === toolCalls.length - 1; chunks.push( - `data: {"choices":[{"delta":{"tool_calls":[{"function":{"arguments":${JSON.stringify(tc.arguments)}}}]}}]}\n\n` + `data: {"choices":[{"delta":{"tool_calls":[{"function":{"arguments":${JSON.stringify(tc.arguments)}}}]}${isLast ? ', "finish_reason":"tool_calls"' : ""}}]}\n\n` ); - } + }); chunks.push(`data: {"usage":{"prompt_tokens":10,"completion_tokens":5}}\n\n`); return makeSSEResponse(chunks); } + // 与 makeToolCallResponse 相同,但 prompt_tokens 可自定义,用于驱动 usageRatio 跨越裁剪阈值 + function makeToolCallResponseWithUsage( + toolCalls: Array<{ id: string; name: string; arguments: string }>, + promptTokens: number + ): Response { + const chunks: string[] = []; + toolCalls.forEach((tc, i) => { + chunks.push( + `data: {"choices":[{"delta":{"tool_calls":[{"id":"${tc.id}","function":{"name":"${tc.name}","arguments":""}}]}}]}\n\n` + ); + const isLast = i === toolCalls.length - 1; + chunks.push( + `data: {"choices":[{"delta":{"tool_calls":[{"function":{"arguments":${JSON.stringify(tc.arguments)}}}]}${isLast ? ', "finish_reason":"tool_calls"' : ""}}]}\n\n` + ); + }); + chunks.push(`data: {"usage":{"prompt_tokens":${promptTokens},"completion_tokens":5}}\n\n`); + return makeSSEResponse(chunks); + } + function createMockSender() { const sentMessages: any[] = []; const mockConn = { @@ -494,6 +514,13 @@ describe("callLLMWithToolLoop 工具调用循环", () => { expect(errorEvents).toHaveLength(1); expect(errorEvents[0].message).toContain("maximum iterations"); expect(errorEvents[0].errorCode).toBe("max_iterations"); + expect(errorEvents[0].usage).toEqual(expect.objectContaining({ inputTokens: 10, outputTokens: 5 })); + expect(errorEvents[0].durationMs).toEqual(expect.any(Number)); + const persistedError = mockRepo.appendMessage.mock.calls + .map((call: any[]) => call[0]) + .find((message: any) => message.errorCode === "max_iterations"); + expect(persistedError.usage).toEqual(expect.objectContaining({ inputTokens: 10, outputTokens: 5 })); + expect(persistedError.durationMs).toEqual(expect.any(Number)); // fetch 只调用 1 次(maxIterations=1) expect(fetchSpy).toHaveBeenCalledTimes(1); @@ -501,6 +528,135 @@ describe("callLLMWithToolLoop 工具调用循环", () => { registry.unregisterBuiltin("loop"); }); + it("显式传入非法 maxIterations(如负数)时应被兜底截断,而非直接导致循环立即失败", async () => { + const { service, mockRepo } = createTestService(); + const { sender, sentMessages } = createMockSender(); + + mockRepo.listConversations.mockResolvedValue([BASE_CONV]); + mockRepo.getMessages.mockResolvedValue([]); + + fetchSpy.mockResolvedValueOnce(makeTextResponse("done")); + + await (service as any).handleConversationChat( + { conversationId: "conv-1", message: "test", maxIterations: -5 }, + sender + ); + + // 不应立即触发 max_iterations 错误:兜底截断为下限后循环至少能执行 1 次 + const events = sentMessages.map((m) => m.data); + const errorEvents = events.filter((e: any) => e.type === "error"); + expect(errorEvents).toHaveLength(0); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("点击继续对话后,历史中的 max_iterations 错误占位消息不应被重放给 LLM", async () => { + const { service, mockRepo } = createTestService(); + const { sender } = createMockSender(); + + mockRepo.listConversations.mockResolvedValue([BASE_CONV]); + // 历史中包含一条超过 max_iterations 时持久化的错误占位消息(content 为空字符串) + mockRepo.getMessages.mockResolvedValue([ + { id: "u1", conversationId: "conv-1", role: "user", content: "第一条消息", createtime: 1 }, + { + id: "a1", + conversationId: "conv-1", + role: "assistant", + content: "", + error: "Tool calling loop exceeded maximum iterations (50)", + errorCode: "max_iterations", + createtime: 2, + }, + ]); + + fetchSpy.mockResolvedValueOnce(makeTextResponse("好的,继续")); + + await (service as any).handleConversationChat({ conversationId: "conv-1", message: "请继续。" }, sender); + + const reqInit = fetchSpy.mock.calls[0][1] as RequestInit; + const body = JSON.parse(reqInit.body as string); + + // 出站请求中不应包含空 content 且无 tool_calls 的 assistant 消息(即错误占位消息) + const emptyAssistantMsgs = body.messages.filter( + (m: any) => m.role === "assistant" && m.content === "" && !m.tool_calls + ); + expect(emptyAssistantMsgs).toHaveLength(0); + + // 正常的历史用户消息与新消息应仍然存在 + const userMsgs = body.messages.filter((m: any) => m.role === "user"); + expect(userMsgs.map((m: any) => m.content)).toEqual(["第一条消息", "请继续。"]); + }); + + it("上下文占用跨过裁剪阈值时应分批裁剪窗口外的旧 tool 结果,窗口内轮次保持原文", async () => { + const { service, mockRepo, mockModelRepo } = createTestService(); + const { sender } = createMockSender(); + + // 使用较小的 contextWindow,便于用少量 prompt_tokens 触发裁剪阈值。 + // 120000:getReservedOutputTokens 现在对未显式配置 maxTokens 的模型也预留非零默认输出额度 + // (见 model_context.ts,finding 11),输入预算公式变成 0.9*contextWindow-16384; + // 调大窗口使输入预算重新接近原先按 0.9*contextWindow 设计时的量级(约 90000), + // 保持下面按 usages 数组设计的"第 5 轮跨 0.4、第 6 轮跨 0.6 但不到 0.8(不触发 autoCompact)"场景 + mockModelRepo.getModel.mockResolvedValue({ + id: "test-openai", + name: "Test", + provider: "openai", + apiBaseUrl: "", + apiKey: "", + model: "gpt-4o", + contextWindow: 120000, + }); + + // 使用两个不同名称的工具交替调用,避免触发 tool_call_guard 的重复调用检测 + // (相同工具名连续出现会命中循环检测,暂停询问用户,与本测试无关) + const registry = (service as any).toolRegistry; + let callCount = 0; + const execute = async () => { + callCount++; + return `count=${callCount}`; + }; + registry.registerBuiltin( + { name: "counterA", description: "Count A", parameters: { type: "object", properties: {} } }, + { execute } + ); + registry.registerBuiltin( + { name: "counterB", description: "Count B", parameters: { type: "object", properties: {} } }, + { execute } + ); + + mockRepo.listConversations.mockResolvedValue([BASE_CONV]); + mockRepo.getMessages.mockResolvedValue([]); + + // 前 4 轮占用较低;第 5 轮跨过 0.4 阈值(此时恰好 5 轮,保留窗口内不裁剪); + // 第 6 轮跨过 0.6 阈值,触发第二次裁剪,第 1 轮此时已超出保留窗口 + const usages = [10000, 10000, 10000, 10000, 50000, 70000]; + for (let i = 0; i < usages.length; i++) { + const toolName = i % 2 === 0 ? "counterA" : "counterB"; + // 每轮参数不同,避免触发 tool_call_guard 的“相同参数重复调用”检测 + fetchSpy.mockResolvedValueOnce( + makeToolCallResponseWithUsage([{ id: `c${i + 1}`, name: toolName, arguments: `{"round":${i}}` }], usages[i]) + ); + } + // 第 7 轮:最终文本,结束循环 + fetchSpy.mockResolvedValueOnce(makeTextResponse("done")); + + await (service as any).handleConversationChat({ conversationId: "conv-1", message: "test" }, sender); + + expect(fetchSpy).toHaveBeenCalledTimes(7); + + // 最后一次 fetch(第 7 轮)请求体中,第 1 轮的 tool 结果应已被裁剪为占位文本, + // 而保留窗口内(第 2~6 轮)应保持原文 + const lastCall = fetchSpy.mock.calls[fetchSpy.mock.calls.length - 1]; + const lastBody = JSON.parse((lastCall[1] as RequestInit).body as string); + const toolMessages = lastBody.messages.filter((m: any) => m.role === "tool"); + + expect(toolMessages).toHaveLength(6); + expect(toolMessages[0].content).toContain("elided"); + expect(toolMessages[1].content).toBe("count=2"); + expect(toolMessages[toolMessages.length - 1].content).toBe("count=6"); + + registry.unregisterBuiltin("counterA"); + registry.unregisterBuiltin("counterB"); + }); + it("工具执行后附件回写:toolCalls 被更新", async () => { const { service, mockRepo } = createTestService(); const { sender, sentMessages } = createMockSender(); @@ -570,6 +726,10 @@ describe("callLLMWithToolLoop 工具调用循环", () => { storedMessages.length = 0; storedMessages.push(...msgs.map((m) => structuredClone(m))); }); + mockRepo.updateMessage.mockImplementation(async (msg: any) => { + const index = storedMessages.findIndex((m) => m.id === msg.id); + if (index >= 0) storedMessages[index] = structuredClone(msg); + }); fetchSpy.mockResolvedValueOnce(makeToolCallResponse([{ id: "call_1", name: "echo", arguments: '{"msg":"hi"}' }])); fetchSpy.mockResolvedValueOnce(makeTextResponse("done")); @@ -637,6 +797,7 @@ describe("callLLMWithToolLoop 工具调用循环", () => { // 应有两个 tool_call_complete const completeEvents = events.filter((e: any) => e.type === "tool_call_complete"); expect(completeEvents).toHaveLength(2); + expect(completeEvents.every((event: any) => event.status === "completed")).toBe(true); expect(completeEvents.find((e: any) => e.id === "call_a").result).toBe("a: hello"); expect(completeEvents.find((e: any) => e.id === "call_b").result).toBe("b: world"); @@ -652,4 +813,39 @@ describe("callLLMWithToolLoop 工具调用循环", () => { registry.unregisterBuiltin("tool_a"); registry.unregisterBuiltin("tool_b"); }); + + it("最终回复持久化多次重试仍失败时应报结构化错误而不是假装 done(finding 10)", async () => { + vi.useFakeTimers(); + try { + const { service, mockRepo } = createTestService(); + const { sender, sentMessages } = createMockSender(); + + mockRepo.listConversations.mockResolvedValue([BASE_CONV]); + mockRepo.getMessages.mockResolvedValue([]); + // 只让最终 assistant 消息持久化失败(模拟 OPFS 写入故障),user 消息正常落库, + // 这样才能验证的是"最终成功回复的落库失败处理"而不是更早的用户消息落库失败 + mockRepo.appendMessage.mockImplementation(async (msg: any) => { + if (msg.role === "assistant") throw new Error("disk write failed"); + }); + + fetchSpy.mockResolvedValueOnce(makeTextResponse("done")); + + const chatPromise = (service as any).handleConversationChat( + { conversationId: "conv-1", message: "test" }, + sender + ); + // 有限重试之间有退避延迟(200ms/400ms),推进假定时器让它们落定 + await vi.advanceTimersByTimeAsync(1000); + await chatPromise; + + const events = sentMessages.map((m) => m.data); + // 不应报告 done:持久化最终失败,不能对外承诺"回复已保存" + expect(events.some((e: any) => e.type === "done")).toBe(false); + const errorEvent = events.find((e: any) => e.type === "error"); + expect(errorEvent).toBeDefined(); + expect(errorEvent.errorCode).toBe("persist_failed"); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/src/app/service/agent/service_worker/llm_client.ts b/src/app/service/agent/service_worker/llm_client.ts index ec076f115..b5941197c 100644 --- a/src/app/service/agent/service_worker/llm_client.ts +++ b/src/app/service/agent/service_worker/llm_client.ts @@ -135,6 +135,26 @@ export class LLMClient { const pendingImageSaves: Array<{ block: ContentBlock & { type: "image" }; data: string }> = []; return new Promise((resolve, reject) => { + // 最后一道保险:即使 provider parser 出现未预见的静默完成路径,也不能让这个 Promise 永远挂起 + let settled = false; + const settleOnce = (fn: () => void) => { + if (settled) return; + settled = true; + signal.removeEventListener("abort", onAbortSafeguard); + fn(); + }; + // parseStream 自身在 abort 时会 reject,并可能携带这一轮已知的部分 usage(见 openai.ts/ + // anthropic.ts)。这里的 signal 监听只是最后一道保险,不能抢在 parseStream 的 reject 之前 + // 立即 settle——那样会用一个不带 usage 的裸 Error 抢占更有信息量的那个 reject(见 finding 7)。 + // 延迟到下一个宏任务,给 parseStream 的 reject 一个先落定的机会,本身仍然是安全网, + // 不依赖它必定生效。 + const onAbortSafeguard = () => { + setTimeout(() => settleOnce(() => reject(new Error("Aborted"))), 0); + }; + signal.addEventListener("abort", onAbortSafeguard, { once: true }); + const resolveOnce: typeof resolve = (value) => settleOnce(() => resolve(value)); + const rejectOnce: typeof reject = (reason) => settleOnce(() => reject(reason)); + const onEvent = (event: ChatStreamEvent) => { // 只转发流式内容事件,done 和 error 由 callLLMWithToolLoop 统一管理 // 避免在 tool calling 循环中提前发送 done 导致客户端过早 resolve @@ -188,12 +208,24 @@ export class LLMClient { usage = event.usage; } - // 保存模型生成的图片到 OPFS,然后转发事件 + // 保存模型生成的图片到 OPFS,然后转发事件。 + // abort 安全:settled 一旦为 true(外层 Promise 已因 abort 落定),后续保存产生的 + // 附件不会再被任何持久化的 assistant 消息引用,是孤儿文件;已发出的 content_block_complete + // 也会晚于终态事件到达客户端。因此每一步都先检查 settled,中途发现已 settle 就 + // 停止继续保存/发送,并清理这一轮已经落盘但用不上的附件(见 finding 6)。 const finalize = async () => { const savedBlocks: ContentBlock[] = []; + // 本轮所有成功落盘的附件 id(不止是取消时正在保存的那一个):一旦 settled, + // finalize() 的返回值不会再被使用(resolveOnce/rejectOnce 已是 no-op), + // 这一轮已经保存的所有附件都变成孤儿文件,必须全部清理,不能只删除取消时 + // 正在保存的那一个而漏掉更早已经保存成功的(见 finding 8) + const allSavedIds: string[] = []; for (const pending of pendingImageSaves) { + if (settled) break; try { await this.chatRepo.saveAttachment(pending.block.attachmentId, pending.data); + allSavedIds.push(pending.block.attachmentId); + if (settled) break; savedBlocks.push(pending.block); // 转发不含 data 的 content_block_complete 事件给 UI sendEvent({ type: "content_block_complete", block: pending.block }); @@ -206,13 +238,15 @@ export class LLMClient { const imgRegex = /!\[([^\]]*)\]\((data:image\/([^;]+);base64,[A-Za-z0-9+/=\s]+)\)/g; let match; let cleanedContent = content; - while ((match = imgRegex.exec(content)) !== null) { + while (!settled && (match = imgRegex.exec(content)) !== null) { const [fullMatch, alt, dataUrl, subtype] = match; const mimeType = `image/${subtype}`; const ext = subtype || "png"; const blockId = generateAttachmentId(ext); try { await this.chatRepo.saveAttachment(blockId, dataUrl); + allSavedIds.push(blockId); + if (settled) break; const block: ContentBlock = { type: "image", attachmentId: blockId, @@ -231,12 +265,16 @@ export class LLMClient { content = cleanedContent.replace(/\n{3,}/g, "\n\n").trim(); } + if (settled && allSavedIds.length > 0) { + await Promise.all(allSavedIds.map((id) => this.chatRepo.deleteAttachment(id).catch(() => {}))); + } + return savedBlocks.length > 0 ? savedBlocks : undefined; }; finalize() .then((contentBlocks) => { - resolve({ + resolveOnce({ content, thinking: thinking || undefined, toolCalls: toolCalls.length > 0 ? toolCalls : undefined, @@ -244,16 +282,23 @@ export class LLMClient { contentBlocks, }); }) - .catch(reject); + .catch(rejectOnce); break; } case "error": - reject(new Error(event.message)); + // 保留 usage/errorCode/durationMs 等字段,不能转成裸 Error 丢掉这些信息(见 finding 7) + rejectOnce( + Object.assign(new Error(event.message), { + errorCode: event.errorCode, + usage: event.usage, + durationMs: event.durationMs, + }) + ); break; } }; - parseStream(reader, onEvent, signal).catch(reject); + parseStream(reader, onEvent, signal).catch(rejectOnce); }); } } diff --git a/src/app/service/agent/service_worker/skill_service.ts b/src/app/service/agent/service_worker/skill_service.ts index 404287771..13c2b7357 100644 --- a/src/app/service/agent/service_worker/skill_service.ts +++ b/src/app/service/agent/service_worker/skill_service.ts @@ -18,6 +18,7 @@ import { cacheInstance } from "@App/app/cache"; import type { ToolExecutor } from "@App/app/service/agent/core/tool_registry"; import type { ResourceService } from "@App/app/service/service_worker/resource"; import { versionCompare } from "@App/pkg/utils/semver"; +import { throwIfAborted } from "@App/app/service/agent/core/abort_utils"; // 更新检查结果 export type SkillUpdateInfo = { @@ -459,7 +460,8 @@ export class SkillService { }, }, executor: { - execute: async (args: Record) => { + execute: async (args: Record, signal?: AbortSignal) => { + throwIfAborted(signal); const skillName = args.skill_name as string; const record = this.skillCache.get(skillName); if (!record) { @@ -512,7 +514,8 @@ export class SkillService { }, }, executor: { - execute: async (args: Record) => { + execute: async (args: Record, signal?: AbortSignal) => { + throwIfAborted(signal); const skillName = args.skill as string; const scriptName = args.script as string; const params = (args.params || {}) as Record; @@ -528,7 +531,7 @@ export class SkillService { ? await this.skillRepo.getConfigValues(skillName) : undefined; const executor = new SkillScriptExecutor(scriptRecord, this.sender, this.createRequireLoader(), configValues); - return executor.execute(params); + return executor.execute(params, signal); }, }, }); @@ -550,7 +553,8 @@ export class SkillService { }, }, executor: { - execute: async (args: Record) => { + execute: async (args: Record, signal?: AbortSignal) => { + throwIfAborted(signal); const skillName = args.skill_name as string; const refName = args.reference_name as string; const ref = await this.skillRepo.getReference(skillName, refName); diff --git a/src/app/service/agent/service_worker/sub_agent_service.test.ts b/src/app/service/agent/service_worker/sub_agent_service.test.ts index 04e50b994..8c9738759 100644 --- a/src/app/service/agent/service_worker/sub_agent_service.test.ts +++ b/src/app/service/agent/service_worker/sub_agent_service.test.ts @@ -63,4 +63,135 @@ describe("SubAgentService", () => { expect(result.result).toBe("result"); expect(orchestrator.callLLMWithToolLoop).toHaveBeenCalledOnce(); }); + + it("max_iterations 错误事件也累计 usage", async () => { + const subOrchestrator = makeMockOrchestrator(); + (subOrchestrator.callLLMWithToolLoop as ReturnType).mockImplementationOnce(async ({ sendEvent }) => { + sendEvent({ + type: "error", + message: "达到上限", + errorCode: "max_iterations", + usage: { inputTokens: 12, outputTokens: 4 }, + }); + }); + service = new SubAgentService(subOrchestrator); + + const result = await service.runSubAgent({ + options: { prompt: "做个任务", description: "测试任务" }, + agentId: "test-agent-2", + model: MODEL, + parentConversationId: "conv-1", + toolRegistry, + sendEvent, + signal, + }); + + expect(result.details?.usage).toEqual(expect.objectContaining({ inputTokens: 12, outputTokens: 4 })); + }); + + it("终态异常保留子代理的部分消息与 usage", async () => { + const subOrchestrator = makeMockOrchestrator(); + (subOrchestrator.callLLMWithToolLoop as ReturnType).mockImplementationOnce(async ({ sendEvent }) => { + sendEvent({ type: "content_delta", delta: "部分结果" }); + throw Object.assign(new Error("达到上限"), { + errorCode: "max_iterations", + usage: { inputTokens: 20, outputTokens: 6 }, + }); + }); + service = new SubAgentService(subOrchestrator); + + await expect( + service.runSubAgent({ + options: { prompt: "做个任务", description: "失败任务" }, + agentId: "test-agent-failed", + model: MODEL, + parentConversationId: "conv-1", + toolRegistry, + sendEvent, + signal, + }) + ).rejects.toMatchObject({ + subAgentDetails: { + messages: [{ content: "部分结果" }], + usage: { inputTokens: 20, outputTokens: 6 }, + }, + }); + }); + + it("编排器抛出的原始异常未经 sendEvent 上报终态时,应补发一次子代理 error 事件,避免 UI 卡在 running", async () => { + const subOrchestrator = makeMockOrchestrator(); + (subOrchestrator.callLLMWithToolLoop as ReturnType).mockImplementationOnce(async () => { + // 模拟 callLLM/autoCompact 的原始失败:orchestrator 直接 throw,从未调用 sendEvent + throw Object.assign(new Error("网络错误"), { usage: { inputTokens: 5, outputTokens: 1 } }); + }); + service = new SubAgentService(subOrchestrator); + + await expect( + service.runSubAgent({ + options: { prompt: "做个任务", description: "失败任务" }, + agentId: "test-agent-raw-fail", + model: MODEL, + parentConversationId: "conv-1", + toolRegistry, + sendEvent, + signal, + }) + ).rejects.toThrow("网络错误"); + + // 必须补发一次终态事件,否则实时 UI 收不到 done/error,会一直显示 running + const errorEvents = (sendEvent as ReturnType).mock.calls + .map((c) => c[0]) + .filter((e) => e.type === "error"); + expect(errorEvents).toHaveLength(1); + expect(errorEvents[0].message).toBe("网络错误"); + }); + + it("已通过 sendEvent 上报终态(如 max_iterations)的异常不应重复补发 error 事件", async () => { + const subOrchestrator = makeMockOrchestrator(); + (subOrchestrator.callLLMWithToolLoop as ReturnType).mockImplementationOnce(async ({ sendEvent }) => { + sendEvent({ type: "error", message: "达到上限", errorCode: "max_iterations" }); + throw Object.assign(new Error("达到上限"), { errorCode: "max_iterations" }); + }); + service = new SubAgentService(subOrchestrator); + + await expect( + service.runSubAgent({ + options: { prompt: "做个任务", description: "失败任务" }, + agentId: "test-agent-reported-fail", + model: MODEL, + parentConversationId: "conv-1", + toolRegistry, + sendEvent, + signal, + }) + ).rejects.toThrow("达到上限"); + + const errorEvents = (sendEvent as ReturnType).mock.calls + .map((c) => c[0]) + .filter((e) => e.type === "error"); + expect(errorEvents).toHaveLength(1); + }); + + it("tool_call_complete 应使用事件自身的 status,失败的嵌套工具重载后仍应显示为 error", async () => { + const subOrchestrator = makeMockOrchestrator(); + (subOrchestrator.callLLMWithToolLoop as ReturnType).mockImplementationOnce(async ({ sendEvent }) => { + sendEvent({ type: "tool_call_start", toolCall: { id: "t1", name: "web_fetch", arguments: "" } }); + sendEvent({ type: "tool_call_complete", id: "t1", result: "失败原因", status: "error" }); + sendEvent({ type: "done" }); + }); + service = new SubAgentService(subOrchestrator); + + const result = await service.runSubAgent({ + options: { prompt: "做个任务", description: "测试任务" }, + agentId: "test-agent-tool-error", + model: MODEL, + parentConversationId: "conv-1", + toolRegistry, + sendEvent, + signal, + }); + + const toolCall = result.details?.messages[0]?.toolCalls[0]; + expect(toolCall?.status).toBe("error"); + }); }); diff --git a/src/app/service/agent/service_worker/sub_agent_service.ts b/src/app/service/agent/service_worker/sub_agent_service.ts index 0cb7a8194..1be82f2d2 100644 --- a/src/app/service/agent/service_worker/sub_agent_service.ts +++ b/src/app/service/agent/service_worker/sub_agent_service.ts @@ -3,6 +3,7 @@ import type { ChatRequest, ChatStreamEvent, SubAgentMessage, + TokenUsage, } from "@App/app/service/agent/core/types"; import type { ToolExecutorLike } from "@App/app/service/agent/core/tool_registry"; import type { SubAgentRunOptions, SubAgentRunResult } from "@App/app/service/agent/core/tools/sub_agent"; @@ -21,6 +22,7 @@ export interface SubAgentOrchestrator { scriptToolCallback: null; excludeTools?: string[]; cache?: boolean; + throwOnTerminalError?: boolean; }): Promise; } @@ -66,19 +68,31 @@ export class SubAgentService { { role: "user", content: userPrompt }, ]; - const { - result, - details, - usage: subUsage, - } = await this.runSubAgentCore({ - toolRegistry, - messages, - model, - excludeTools, - maxIterations: typeConfig.maxIterations, - sendEvent, - signal, - }); + let coreResult: Awaited>; + try { + coreResult = await this.runSubAgentCore({ + toolRegistry, + messages, + model, + excludeTools, + maxIterations: typeConfig.maxIterations, + sendEvent, + signal, + }); + } catch (error) { + const terminalError = error instanceof Error ? error : new Error(String(error)); + const partial = terminalError as Error & { details?: SubAgentMessage[]; usage?: TokenUsage }; + (terminalError as Error & { subAgentDetails?: NonNullable }).subAgentDetails = { + agentId, + description: options.description, + subAgentType: typeConfig.name, + messages: partial.details || [], + usage: partial.usage, + }; + throw terminalError; + } + + const { result, details, usage: subUsage } = coreResult; return { agentId, @@ -118,6 +132,9 @@ export class SubAgentService { let currentMsg: SubAgentMessage = { content: "", toolCalls: [] }; // 累计 usage const subUsage = { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 }; + // 是否已通过 sendEvent 转发过终态(done/error)。orchestrator 在 callLLM/autoCompact + // 原生失败时只 throw、不 sendEvent,若不补发,实时 UI 收不到子代理的终态事件,会一直显示 running。 + let terminalEventEmitted = false; const subSendEvent = (event: ChatStreamEvent) => { // 转发事件给父代理 @@ -156,7 +173,7 @@ export class SubAgentService { case "tool_call_complete": { const tc = currentMsg.toolCalls.find((t) => t.id === event.id); if (tc) { - tc.status = "completed"; + tc.status = event.status ?? "completed"; tc.result = event.result; tc.attachments = event.attachments; } @@ -171,6 +188,8 @@ export class SubAgentService { currentMsg = { content: "", toolCalls: [] }; break; case "done": + case "error": + terminalEventEmitted = true; if (event.usage) { subUsage.inputTokens += event.usage.inputTokens; subUsage.outputTokens += event.usage.outputTokens; @@ -181,17 +200,41 @@ export class SubAgentService { } }; - await this.orchestrator.callLLMWithToolLoop({ - toolRegistry: params.toolRegistry, - model: params.model, - messages: params.messages, - maxIterations: params.maxIterations, - sendEvent: subSendEvent, - signal: params.signal, - scriptToolCallback: null, - excludeTools: params.excludeTools, - cache: false, - }); + try { + await this.orchestrator.callLLMWithToolLoop({ + toolRegistry: params.toolRegistry, + model: params.model, + messages: params.messages, + maxIterations: params.maxIterations, + sendEvent: subSendEvent, + signal: params.signal, + scriptToolCallback: null, + excludeTools: params.excludeTools, + cache: false, + throwOnTerminalError: true, + }); + } catch (error) { + const terminalError = error instanceof Error ? error : new Error(String(error)); + if (currentMsg.content || currentMsg.thinking || currentMsg.toolCalls.length > 0) { + details.push(currentMsg); + } + const terminalUsage = (terminalError as Error & { usage?: TokenUsage }).usage; + if (terminalUsage) Object.assign(subUsage, terminalUsage); + // orchestrator 的 callLLM/autoCompact 原生失败只 throw、不 sendEvent 终态; + // 若不在此补发一次,实时 UI(依赖 subAgent 元信息的 done/error 事件)会一直显示 running。 + if (!terminalEventEmitted) { + const errorLike = terminalError as Error & { errorCode?: string; durationMs?: number }; + params.sendEvent({ + type: "error", + message: terminalError.message, + errorCode: errorLike.errorCode, + usage: subUsage, + durationMs: errorLike.durationMs, + }); + } + Object.assign(terminalError, { details, usage: subUsage }); + throw terminalError; + } // 检查是否因超时中止(区分用户主动取消和超时) if (params.signal.aborted) { diff --git a/src/app/service/agent/service_worker/task_service.ts b/src/app/service/agent/service_worker/task_service.ts index f5101b52f..362e07ce2 100644 --- a/src/app/service/agent/service_worker/task_service.ts +++ b/src/app/service/agent/service_worker/task_service.ts @@ -21,6 +21,8 @@ import { uuidv4 } from "@App/pkg/utils/uuid"; import { nextTimeInfo } from "@App/pkg/utils/cron"; import { InfoNotification } from "@App/app/service/service_worker/utils"; import { sendMessage } from "@Packages/message/client"; +import { normalizeChatMaxIterations } from "@App/app/service/agent/core/agent_config"; +import { toLLMMessages } from "@App/app/service/agent/core/persisted_messages"; /** 供 TaskService 调用的 orchestrator 能力 */ export interface TaskOrchestrator { @@ -34,6 +36,8 @@ export interface TaskOrchestrator { signal: AbortSignal; scriptToolCallback: ScriptToolCallback | null; conversationId: string; + rehydratedHistory?: boolean; + throwOnTerminalError?: boolean; }): Promise; } @@ -115,12 +119,7 @@ export class AgentTaskService { for (const msg of existingMessages) { if (msg.role === "system") continue; - messages.push({ - role: msg.role, - content: msg.content, - toolCallId: msg.toolCallId, - toolCalls: msg.toolCalls, - }); + messages.push(...toLLMMessages([msg]).filter((message) => message.role !== "system")); } } } else { @@ -157,7 +156,7 @@ export class AgentTaskService { const sendEvent = (event: ChatStreamEvent) => { // 定时任务无 UI 连接,但需要收集 usage - if (event.type === "done" && event.usage) { + if ((event.type === "done" || event.type === "error") && event.usage) { totalUsage.inputTokens += event.usage.inputTokens; totalUsage.outputTokens += event.usage.outputTokens; } @@ -167,11 +166,13 @@ export class AgentTaskService { toolRegistry: sessionRegistry, model, messages, - maxIterations: task.maxIterations || 10, + maxIterations: normalizeChatMaxIterations(task.maxIterations ?? 10), sendEvent, signal: abortController.signal, scriptToolCallback: null, conversationId, + rehydratedHistory: Boolean(task.conversationId), + throwOnTerminalError: true, }); // 通知 diff --git a/src/app/service/agent/service_worker/test-helpers.ts b/src/app/service/agent/service_worker/test-helpers.ts index 17b86146a..7077cfe4a 100644 --- a/src/app/service/agent/service_worker/test-helpers.ts +++ b/src/app/service/agent/service_worker/test-helpers.ts @@ -39,13 +39,38 @@ export function createTestService() { const mockGroup = { on: vi.fn() } as any; const mockSender = {} as any; + // 消息按 conversationId 存储,供下面的 appendMessage/updateMessage/getMessages mock 共享, + // 让 updateMessage 的按 id 替换语义在测试里也能被后续 getMessages 断言观察到(与真实 repo 行为一致)。 + // 真实 OPFS repo 每次读写都经过 JSON 序列化往返,调用方之间不共享对象引用; + // 这里的 mock 也必须在存取时做深拷贝,否则某次调用记录下的数组引用会被后续调用原地修改 + // (例如 saveMessages 记录的数组之后被 appendMessage push,导致 vi.fn() 的 mock.calls 断言看到"事后被改过"的内容)。 + const messagesByConv = new Map(); + // 重置 agent_chat 单例 mock 方法(保持对象身份不变,只替换 vi.fn) Object.assign(mockChatRepo, { - appendMessage: vi.fn().mockResolvedValue(undefined), - getMessages: vi.fn().mockResolvedValue([]), + appendMessage: vi.fn().mockImplementation(async (message: any) => { + const list = messagesByConv.get(message.conversationId) ?? []; + list.push(structuredClone(message)); + messagesByConv.set(message.conversationId, list); + }), + getMessages: vi + .fn() + .mockImplementation(async (conversationId: string) => + (messagesByConv.get(conversationId) ?? []).map((m) => structuredClone(m)) + ), listConversations: vi.fn().mockResolvedValue([]), saveConversation: vi.fn().mockResolvedValue(undefined), - saveMessages: vi.fn().mockResolvedValue(undefined), + saveMessages: vi.fn().mockImplementation(async (conversationId: string, messages: any[]) => { + messagesByConv.set( + conversationId, + messages.map((m) => structuredClone(m)) + ); + }), + updateMessage: vi.fn().mockImplementation(async (message: any) => { + const list = messagesByConv.get(message.conversationId) ?? []; + const index = list.findIndex((m) => m.id === message.id); + if (index >= 0) list[index] = structuredClone(message); + }), getTasks: vi.fn().mockResolvedValue([]), saveTasks: vi.fn().mockResolvedValue(undefined), getAttachment: vi.fn().mockResolvedValue(null), @@ -178,7 +203,7 @@ export function createMockSenderWithCallbacks() { export function makeTextResponse(text: string): Response { const encoder = new TextEncoder(); const chunks = [ - `data: {"choices":[{"delta":{"content":"${text}"}}]}\n\n`, + `data: {"choices":[{"delta":{"content":"${text}"},"finish_reason":"stop"}]}\n\n`, `data: {"usage":{"prompt_tokens":10,"completion_tokens":5}}\n\n`, ]; let i = 0; diff --git a/src/app/service/agent/service_worker/tool_loop_orchestrator.test.ts b/src/app/service/agent/service_worker/tool_loop_orchestrator.test.ts new file mode 100644 index 000000000..ec28508f4 --- /dev/null +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator.test.ts @@ -0,0 +1,341 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ToolLoopOrchestrator, type ToolLoopDeps } from "./tool_loop_orchestrator"; +import type { ToolExecutorLike, ToolExecuteResult } from "@App/app/service/agent/core/tool_registry"; +import type { ToolCall, AgentModelConfig, ChatRequest, ChatStreamEvent } from "@App/app/service/agent/core/types"; +import { estimateRequestTokens } from "@App/app/service/agent/core/context_elision"; +import { getInputTokenBudget } from "@App/app/service/agent/core/model_context"; +import type { LLMCallResult } from "./llm_client"; + +const MODEL: AgentModelConfig = { + id: "m1", + name: "Test", + provider: "openai", + apiBaseUrl: "", + apiKey: "", + model: "gpt-4o", +}; + +function makeFakeChatRepo() { + return { + appendMessage: vi.fn().mockResolvedValue(undefined), + getMessages: vi.fn().mockResolvedValue([]), + saveMessages: vi.fn().mockResolvedValue(undefined), + updateMessage: vi.fn().mockResolvedValue(undefined), + } as any; +} + +function makeFakeToolRegistry(): ToolExecutorLike { + return { + getDefinitions: () => [{ name: "dup", description: "dup", parameters: { type: "object", properties: {} } }], + execute: async (toolCalls: ToolCall[]): Promise => + toolCalls.map((tc) => ({ id: tc.id, result: "ok" })), + }; +} + +// 连续 4 轮调用同一工具 dup 且参数完全相同(触发两次重复调用告警:第 2、4 轮) +function dupToolCallResult(id: string): LLMCallResult { + return { content: "", toolCalls: [{ id, name: "dup", arguments: "{}" }] } as LLMCallResult; +} + +function finalTextResult(text: string): LLMCallResult { + return { content: text } as LLMCallResult; +} + +describe("ToolLoopOrchestrator 循环检测升级(loop-guard escalation)", () => { + let chatRepo: ReturnType; + let toolRegistry: ToolExecutorLike; + let callLLM: ReturnType>; + let autoCompact: ReturnType>; + let deps: ToolLoopDeps; + let orchestrator: ToolLoopOrchestrator; + let sendEvent: ReturnType; + + beforeEach(() => { + chatRepo = makeFakeChatRepo(); + toolRegistry = makeFakeToolRegistry(); + callLLM = vi.fn(); + autoCompact = vi.fn().mockResolvedValue(undefined); + deps = { callLLM, autoCompact }; + orchestrator = new ToolLoopOrchestrator(deps, chatRepo); + sendEvent = vi.fn(); + }); + + function baseParams(overrides: Record = {}) { + return { + toolRegistry, + model: MODEL, + messages: [{ role: "user", content: "开始" }] as ChatRequest["messages"], + maxIterations: 10, + sendEvent: sendEvent as (event: ChatStreamEvent) => void, + signal: new AbortController().signal, + scriptToolCallback: null, + conversationId: "conv-1", + rehydratedHistory: true, + ...overrides, + }; + } + + it("未提供 askUserForGuard 时,重复调用告警照常触发但不暂停循环", async () => { + callLLM + .mockResolvedValueOnce(dupToolCallResult("c1")) + .mockResolvedValueOnce(dupToolCallResult("c2")) + .mockResolvedValueOnce(dupToolCallResult("c3")) + .mockResolvedValueOnce(dupToolCallResult("c4")) + .mockResolvedValueOnce(finalTextResult("done")); + + await orchestrator.callLLMWithToolLoop(baseParams()); + + expect(callLLM).toHaveBeenCalledTimes(5); + const warningEvents = sendEvent.mock.calls.filter((c) => c[0].type === "system_warning"); + expect(warningEvents).toHaveLength(2); + const doneEvents = sendEvent.mock.calls.filter((c) => c[0].type === "done"); + expect(doneEvents).toHaveLength(1); + }); + + it("auto compact 失败时抛出包含累计 usage 与 conversationId 的结构化错误", async () => { + callLLM.mockResolvedValue({ content: "继续", usage: { inputTokens: 9000, outputTokens: 5 } }); + autoCompact.mockRejectedValue(new Error("compact failed")); + + await expect( + orchestrator.callLLMWithToolLoop( + // 30000:getReservedOutputTokens 现在对未显式配置 maxTokens 的模型也预留非零默认输出额度 + // (见 model_context.ts,finding 11),10000 的窗口扣除后输入预算会塌缩到 0, + // 这里放宽窗口以保留测试原本想验证的 usage/contextWindow 比例触发 autoCompact 的场景 + baseParams({ model: { ...MODEL, contextWindow: 30000 }, messages: [{ role: "user", content: "开始" }] }) + ) + ).rejects.toMatchObject({ + message: "compact failed", + conversationId: "conv-1", + usage: { inputTokens: 9000, outputTokens: 5 }, + }); + }); + + it("触发 autoCompact 时应保留原始模型,而不是把 contextWindow 预先缩小", async () => { + callLLM.mockResolvedValue({ content: "done", usage: { inputTokens: 6000, outputTokens: 5 } }); + + await orchestrator.callLLMWithToolLoop( + baseParams({ model: { ...MODEL, contextWindow: 10_000, maxTokens: 2_000 } }) + ); + + expect(callLLM).toHaveBeenCalledTimes(1); + expect(callLLM.mock.calls[0][0]).toMatchObject({ contextWindow: 10_000, maxTokens: 2_000 }); + expect(autoCompact).toHaveBeenCalledTimes(1); + expect(autoCompact.mock.calls[0][1]).toMatchObject({ contextWindow: 10_000, maxTokens: 2_000 }); + }); + + it("自动压缩的 token 用量应计入最终 done 事件", async () => { + callLLM.mockResolvedValue({ content: "done", usage: { inputTokens: 6000, outputTokens: 5 } }); + autoCompact.mockResolvedValue({ + inputTokens: 120, + outputTokens: 30, + cacheCreationInputTokens: 10, + cacheReadInputTokens: 5, + }); + + await orchestrator.callLLMWithToolLoop( + baseParams({ model: { ...MODEL, contextWindow: 10_000, maxTokens: 2_000 } }) + ); + + const doneEvent = sendEvent.mock.calls.find((call) => call[0].type === "done")?.[0]; + expect(doneEvent).toMatchObject({ + usage: { inputTokens: 6120, outputTokens: 35, cacheCreationInputTokens: 10, cacheReadInputTokens: 5 }, + }); + }); + + it("续接长历史时,首个 LLM 请求使用裁剪后的副本且不修改原始历史", async () => { + const oldToolResult = "完整工具结果".repeat(100); + const messages: ChatRequest["messages"] = []; + for (let i = 0; i < 6; i++) { + messages.push({ + role: "assistant", + content: "", + toolCalls: [{ id: `tc${i}`, name: "dup", arguments: "{}" }], + }); + messages.push({ role: "tool", content: oldToolResult, toolCallId: `tc${i}` }); + } + callLLM.mockImplementation(async (_model, request) => { + expect(request.messages[1].content).toBe( + "[tool result elided to save context — re-run the tool if you need this data again]" + ); + expect(messages[1].content).toBe(oldToolResult); + return finalTextResult("done"); + }); + + await orchestrator.callLLMWithToolLoop(baseParams({ messages, model: { ...MODEL, contextWindow: 1000 } })); + }); + + it("续接短历史时,估算上下文未达到 40% 不应预先裁剪工具结果", async () => { + const messages: ChatRequest["messages"] = []; + for (let i = 0; i < 6; i++) { + messages.push({ + role: "assistant", + content: "", + toolCalls: [{ id: `short${i}`, name: "dup", arguments: "{}" }], + }); + messages.push({ role: "tool", content: "短结果", toolCallId: `short${i}` }); + } + callLLM.mockImplementation(async (_model, request) => { + expect(request.messages[1].content).toBe("短结果"); + return finalTextResult("done"); + }); + + await orchestrator.callLLMWithToolLoop(baseParams({ messages })); + }); + + it("第 2 次触发告警时应暂停并询问用户;回答非 Stop 时应继续循环", async () => { + callLLM + .mockResolvedValueOnce(dupToolCallResult("c1")) + .mockResolvedValueOnce(dupToolCallResult("c2")) + .mockResolvedValueOnce(dupToolCallResult("c3")) + .mockResolvedValueOnce(dupToolCallResult("c4")) + .mockResolvedValueOnce(finalTextResult("done")); + + const askUserForGuard = vi.fn().mockResolvedValue("Continue"); + + await orchestrator.callLLMWithToolLoop(baseParams({ askUserForGuard })); + + expect(askUserForGuard).toHaveBeenCalledTimes(1); + expect(askUserForGuard.mock.calls[0][0]).toBe(2); + + expect(callLLM).toHaveBeenCalledTimes(5); + const doneEvents = sendEvent.mock.calls.filter((c) => c[0].type === "done"); + expect(doneEvents).toHaveLength(1); + }); + + it("回答 Stop 时应提前结束循环,以 done(而非 error)收尾", async () => { + callLLM + .mockResolvedValueOnce(dupToolCallResult("c1")) + .mockResolvedValueOnce(dupToolCallResult("c2")) + .mockResolvedValueOnce(dupToolCallResult("c3")) + .mockResolvedValueOnce(dupToolCallResult("c4")) + .mockResolvedValueOnce(finalTextResult("done")); // 若未提前结束,不应被调用到 + + const askUserForGuard = vi.fn().mockResolvedValue("Stop"); + + await orchestrator.callLLMWithToolLoop(baseParams({ askUserForGuard })); + + // 应在第 4 轮后立即停止,不再发起第 5 次 LLM 调用 + expect(callLLM).toHaveBeenCalledTimes(4); + + const errorEvents = sendEvent.mock.calls.filter((c) => c[0].type === "error"); + expect(errorEvents).toHaveLength(0); + const doneEvents = sendEvent.mock.calls.filter((c) => c[0].type === "done"); + expect(doneEvents).toHaveLength(1); + + // 停止信息应被持久化 + const assistantCalls = chatRepo.appendMessage.mock.calls.map((c: any) => c[0]).filter((m: any) => m.error == null); + const stopMessage = assistantCalls.find((m: any) => typeof m.content === "string"); + expect(stopMessage).toBeDefined(); + }); + + it("回答 Continue 后应重置命中计数,之后需再次连续命中 2 次才会重新暂停询问", async () => { + // 第 1~4 轮:触发前两次命中(第 2、4 轮),第 4 轮暂停询问,回答 Continue + // 第 5~6 轮:命中第 3 次(重置后的第 1 次),不应再次暂停 + // 第 7~8 轮:命中第 4 次(重置后的第 2 次),应再次暂停询问 + // 第 9 轮:最终文本,结束循环 + for (let i = 1; i <= 8; i++) { + callLLM.mockResolvedValueOnce(dupToolCallResult(`c${i}`)); + } + callLLM.mockResolvedValueOnce(finalTextResult("done")); + + const askUserForGuard = vi.fn().mockResolvedValue("Continue"); + + await orchestrator.callLLMWithToolLoop(baseParams({ askUserForGuard })); + + // 仅在第 4 轮和第 8 轮各暂停一次,中间第 6 轮的命中(重置后第 1 次)不应触发暂停 + expect(askUserForGuard).toHaveBeenCalledTimes(2); + expect(askUserForGuard.mock.calls[0][0]).toBe(2); + expect(askUserForGuard.mock.calls[1][0]).toBe(2); + + expect(callLLM).toHaveBeenCalledTimes(9); + const doneEvents = sendEvent.mock.calls.filter((c) => c[0].type === "done"); + expect(doneEvents).toHaveLength(1); + }); +}); + +describe("ToolLoopOrchestrator 请求前预算检查(防止 tool 结果把下一次请求撑爆)", () => { + let chatRepo: ReturnType; + let toolRegistry: ToolExecutorLike; + let callLLM: ReturnType>; + let autoCompact: ReturnType>; + let deps: ToolLoopDeps; + let orchestrator: ToolLoopOrchestrator; + let sendEvent: ReturnType; + + beforeEach(() => { + chatRepo = makeFakeChatRepo(); + toolRegistry = makeFakeToolRegistry(); + callLLM = vi.fn(); + autoCompact = vi.fn().mockResolvedValue(undefined); + deps = { callLLM, autoCompact }; + orchestrator = new ToolLoopOrchestrator(deps, chatRepo); + sendEvent = vi.fn(); + }); + + function baseParams(overrides: Record = {}) { + return { + toolRegistry, + // 20000:getReservedOutputTokens 现在对未显式配置 maxTokens 的模型也预留非零默认输出额度 + // (见 model_context.ts,finding 11),1000 的窗口扣除后输入预算会塌缩到 0, + // 这里放宽窗口,同时仍远小于下面 hugeResult 折算后的 token 数,保留"预算检查触发裁剪"场景 + model: { ...MODEL, contextWindow: 20000 }, + messages: [{ role: "user", content: "开始" }] as ChatRequest["messages"], + maxIterations: 10, + sendEvent: sendEvent as (event: ChatStreamEvent) => void, + signal: new AbortController().signal, + scriptToolCallback: null, + conversationId: "conv-1", + ...overrides, + }; + } + + it("上一轮 tool 结果把下一次请求撑爆时,应在发送前裁剪,而不是等 usage 反馈后再处理", async () => { + // 第 1 轮:无 usage 反馈(模拟未携带 usage 的响应),但工具返回一段远超上下文窗口的巨大结果。 + // 若没有“发送前预算检查”,第 2 轮请求会直接带着这段巨大文本发出去。 + const hugeResult = "巨大的工具结果".repeat(2000); + toolRegistry = { + getDefinitions: () => [{ name: "dup", description: "dup", parameters: { type: "object", properties: {} } }], + execute: async (toolCalls: ToolCall[]): Promise => + toolCalls.map((tc) => ({ id: tc.id, result: hugeResult })), + }; + deps = { callLLM, autoCompact }; + orchestrator = new ToolLoopOrchestrator(deps, chatRepo); + + callLLM + .mockResolvedValueOnce({ content: "", toolCalls: [{ id: "c1", name: "dup", arguments: "{}" }] } as LLMCallResult) + .mockImplementationOnce(async (_model, request) => { + // 第 2 次请求发出前应已裁剪掉巨大的 tool 结果 + const toolMsg = request.messages.find((m) => m.role === "tool"); + expect(toolMsg?.content).not.toBe(hugeResult); + return finalTextResult("done"); + }); + + await orchestrator.callLLMWithToolLoop(baseParams({ toolRegistry })); + + expect(callLLM).toHaveBeenCalledTimes(2); + }); + + it("已计算的输入预算不应再被额外保留 10%", async () => { + const model = { ...MODEL, contextWindow: 10_000, maxTokens: 2_000 }; + const inputBudget = getInputTokenBudget(model); + const tools = toolRegistry.getDefinitions(); + let low = 0; + let high = 40_000; + while (low < high) { + const middle = Math.floor((low + high) / 2); + const estimate = estimateRequestTokens([{ role: "user", content: "x".repeat(middle) }], tools, undefined, model); + if (estimate < inputBudget * 0.95) low = middle + 1; + else high = middle; + } + const messages: ChatRequest["messages"] = [{ role: "user", content: "x".repeat(low) }]; + const estimatedTokens = estimateRequestTokens(messages, tools, undefined, model); + expect(estimatedTokens).toBeGreaterThan(inputBudget * 0.9); + expect(estimatedTokens).toBeLessThanOrEqual(inputBudget); + callLLM.mockResolvedValue(finalTextResult("done")); + + await orchestrator.callLLMWithToolLoop(baseParams({ model, messages })); + + expect(callLLM).toHaveBeenCalledOnce(); + expect(sendEvent.mock.calls.some((call) => call[0].type === "error")).toBe(false); + }); +}); diff --git a/src/app/service/agent/service_worker/tool_loop_orchestrator.ts b/src/app/service/agent/service_worker/tool_loop_orchestrator.ts index 09ab85769..41def8242 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator.ts @@ -1,289 +1,19 @@ import type { AgentChatRepo } from "@App/app/repo/agent_chat"; -import type { ScriptToolCallback, ToolExecutorLike } from "@App/app/service/agent/core/tool_registry"; -import type { - AgentModelConfig, - ChatRequest, - ChatStreamEvent, - ToolDefinition, - ToolCall, - Attachment, - SubAgentDetails, - ContentBlock, - MessageContent, -} from "@App/app/service/agent/core/types"; -import { uuidv4 } from "@App/pkg/utils/uuid"; -import { getContextWindow } from "@App/app/service/agent/core/model_context"; -import { detectToolCallIssues, type ToolCallRecord } from "@App/app/service/agent/core/tool_call_guard"; -import type { LLMCallResult } from "./llm_client"; +import { getInputTokenBudget } from "@App/app/service/agent/core/model_context"; +import { ToolLoopOrchestrator as BaseToolLoopOrchestrator, type ToolLoopDeps } from "./tool_loop_orchestrator_base"; -/** ToolLoopOrchestrator 所需的外部依赖(由 AgentService 注入) */ -export interface ToolLoopDeps { - // callLLM 通过 lambda 注入,确保测试 spy 可以拦截 - callLLM( - model: AgentModelConfig, - params: { messages: ChatRequest["messages"]; tools?: ToolDefinition[]; cache?: boolean }, - sendEvent: (event: ChatStreamEvent) => void, - signal: AbortSignal - ): Promise; - autoCompact( - conversationId: string, - model: AgentModelConfig, - messages: ChatRequest["messages"], - sendEvent: (event: ChatStreamEvent) => void, - signal: AbortSignal - ): Promise; -} +export type { ToolLoopDeps } from "./tool_loop_orchestrator_base"; +/** 在原编排器外层统一预留输出 token 预算。 */ export class ToolLoopOrchestrator { - // 注意:不在构造器持有 toolRegistry。 - // 每次 callLLMWithToolLoop 由调用方传入(通常是 SessionToolRegistry), - // 保证并发会话各自使用独立的工具注册表,避免闭包互相覆盖。 constructor( private deps: ToolLoopDeps, private chatRepo: AgentChatRepo ) {} - // 统一的 tool calling 循环,UI 和脚本共用 - async callLLMWithToolLoop(params: { - // 本次调用使用的工具注册表(SessionToolRegistry 或 ToolRegistry) - toolRegistry: ToolExecutorLike; - model: AgentModelConfig; - messages: ChatRequest["messages"]; - tools?: ToolDefinition[]; - maxIterations: number; - sendEvent: (event: ChatStreamEvent) => void; - signal: AbortSignal; - // 脚本自定义工具的回调,null 表示只用内置工具 - scriptToolCallback: ScriptToolCallback | null; - // 对话 ID,用于持久化消息(可选,UI 场景由 hooks 自行持久化) - conversationId?: string; - // 跳过内置工具,仅使用传入的 tools(ephemeral 模式) - skipBuiltinTools?: boolean; - // 排除的工具名称列表(子代理不可用 ask_user、agent) - excludeTools?: string[]; - // 是否启用 prompt caching,默认 true - cache?: boolean; - }): Promise { - const { - toolRegistry, - model, - messages, - tools, - maxIterations, - sendEvent, - signal, - scriptToolCallback, - conversationId, - } = params; - - const startTime = Date.now(); - let iterations = 0; - const totalUsage = { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 }; - const toolCallHistory: ToolCallRecord[] = []; - let guardStartIndex = 0; - - while (iterations < maxIterations) { - iterations++; - - // 每轮重新获取工具定义(load_skill 可能动态注册了新工具) - let allToolDefs = params.skipBuiltinTools ? tools || [] : toolRegistry.getDefinitions(tools); - if (params.excludeTools && params.excludeTools.length > 0) { - const excludeSet = new Set(params.excludeTools); - allToolDefs = allToolDefs.filter((t) => !excludeSet.has(t.name)); - } - - // 调用 LLM(重试由 llm_client 内部处理) - const result = await this.deps.callLLM( - model, - { messages, tools: allToolDefs.length > 0 ? allToolDefs : undefined, cache: params.cache }, - sendEvent, - signal - ); - - if (signal.aborted) return; - - // 累计 usage - if (result.usage) { - totalUsage.inputTokens += result.usage.inputTokens; - totalUsage.outputTokens += result.usage.outputTokens; - totalUsage.cacheCreationInputTokens += result.usage.cacheCreationInputTokens || 0; - totalUsage.cacheReadInputTokens += result.usage.cacheReadInputTokens || 0; - } - - // 自动 compact:当上下文占用超过 80% 时触发 - if (result.usage && conversationId) { - const contextWindow = getContextWindow(model); - const usageRatio = result.usage.inputTokens / contextWindow; - - if (usageRatio >= 0.8) { - await this.deps.autoCompact(conversationId, model, messages, sendEvent, signal); - } - } - - // 构建 assistant 消息的持久化内容(合并文本和生成的图片 blocks) - const buildMessageContent = (): MessageContent => { - if (result.contentBlocks && result.contentBlocks.length > 0) { - const blocks: ContentBlock[] = []; - if (result.content) blocks.push({ type: "text", text: result.content }); - blocks.push(...result.contentBlocks); - return blocks; - } - return result.content; - }; - - // 如果有 tool calls,需要执行并继续循环 - if (result.toolCalls && result.toolCalls.length > 0 && allToolDefs.length > 0) { - // 持久化 assistant 消息(含 tool calls) - if (conversationId) { - await this.chatRepo.appendMessage({ - id: uuidv4(), - conversationId, - role: "assistant", - content: buildMessageContent(), - thinking: result.thinking ? { content: result.thinking } : undefined, - toolCalls: result.toolCalls, - createtime: Date.now(), - }); - } - - // 将 assistant 消息加入上下文(带 toolCalls,供 provider 构建 tool_calls 字段) - messages.push({ role: "assistant", content: result.content || "", toolCalls: result.toolCalls }); - - // 通过 ToolRegistry 执行工具(内置工具直接执行,脚本工具回调 Sandbox) - // excludeTools 做后端强校验:被排除的工具名直接返回 error,避免 LLM 盲调绕过白/黑名单 - const excludeToolsSet = - params.excludeTools && params.excludeTools.length > 0 ? new Set(params.excludeTools) : undefined; - const toolResults = await toolRegistry.execute(result.toolCalls, scriptToolCallback, excludeToolsSet); - - // 将 tool 结果加入消息,并通知 UI 工具执行完成 - // 收集需要回写的 toolCall 元数据(执行状态 / 附件 / 子代理详情) - const attachmentUpdates = new Map(); - const subAgentUpdates = new Map(); - const completedIds = new Set(); - - for (const tr of toolResults) { - // LLM 上下文只包含文本结果,不含附件 - messages.push({ role: "tool", content: tr.result, toolCallId: tr.id }); - // 通知 UI 工具执行完成(含附件元数据) - sendEvent({ type: "tool_call_complete", id: tr.id, result: tr.result, attachments: tr.attachments }); - - completedIds.add(tr.id); - if (tr.attachments?.length) { - attachmentUpdates.set(tr.id, tr.attachments); - } - if (tr.subAgentDetails) { - subAgentUpdates.set(tr.id, tr.subAgentDetails); - } - - // 持久化 tool 结果消息 - if (conversationId) { - await this.chatRepo.appendMessage({ - id: uuidv4(), - conversationId, - role: "tool", - content: tr.result, - toolCallId: tr.id, - createtime: Date.now(), - }); - } - } - - // 回写工具执行结果到 assistant 消息的 toolCalls(内存 + 持久化)。 - // assistant 消息在执行前已落库,其 toolCalls 的 status 仍是 "running"; - // 必须在此回写为 "completed",否则刷新/重载会从库里读回 running,导致工具图标一直转圈。 - const applyToolUpdates = (toolCalls: ToolCall[]) => { - for (const tc of toolCalls) { - if (completedIds.has(tc.id)) tc.status = "completed"; - const atts = attachmentUpdates.get(tc.id); - if (atts) tc.attachments = atts; - const sad = subAgentUpdates.get(tc.id); - if (sad) tc.subAgentDetails = sad; - } - }; - - // 内存上下文中的 assistant 消息 - const assistantMsg = messages.find( - (m) => m.role === "assistant" && m.toolCalls?.some((tc: ToolCall) => completedIds.has(tc.id)) - ); - if (assistantMsg?.toolCalls) applyToolUpdates(assistantMsg.toolCalls); - - // 持久化的 assistant 消息 - if (conversationId) { - const allMessages = await this.chatRepo.getMessages(conversationId); - for (let i = allMessages.length - 1; i >= 0; i--) { - const msg = allMessages[i]; - if (msg.role === "assistant" && msg.toolCalls?.some((tc: ToolCall) => completedIds.has(tc.id))) { - applyToolUpdates(msg.toolCalls!); - await this.chatRepo.saveMessages(conversationId, allMessages); - break; - } - } - } - - // 记录工具调用历史用于模式检测 - const resultMap = new Map(toolResults.map((r) => [r.id, r])); - for (const tc of result.toolCalls) { - const tr = resultMap.get(tc.id); - toolCallHistory.push({ - name: tc.name, - args: tc.arguments, - result: tr?.result ?? "", - iteration: iterations, - }); - } - - // 工具调用模式检测:检测重复/循环模式并注入针对性提醒 - // 每次警告后推进 startIndex,避免旧记录持续触发同一条警告 - const toolCallWarning = detectToolCallIssues(toolCallHistory, guardStartIndex); - if (toolCallWarning) { - guardStartIndex = toolCallHistory.length; - messages.push({ role: "user", content: toolCallWarning }); - sendEvent({ type: "system_warning", message: toolCallWarning }); - } - - // 通知 UI 即将开始新一轮 LLM 调用,创建新的 assistant 消息 - sendEvent({ type: "new_message" }); - - // 继续循环 - continue; - } - - // 没有 tool calls,对话结束 - const durationMs = Date.now() - startTime; - if (conversationId) { - await this.chatRepo.appendMessage({ - id: uuidv4(), - conversationId, - role: "assistant", - content: buildMessageContent(), - thinking: result.thinking ? { content: result.thinking } : undefined, - usage: totalUsage, - durationMs, - createtime: Date.now(), - }); - } - - // 发送 done 事件 - sendEvent({ type: "done", usage: totalUsage, durationMs }); - return; - } - - // 超过最大迭代次数 - const maxIterMsg = `Tool calling loop exceeded maximum iterations (${maxIterations})`; - if (conversationId) { - await this.chatRepo.appendMessage({ - id: uuidv4(), - conversationId, - role: "assistant", - content: "", - error: maxIterMsg, - createtime: Date.now(), - }); - } - sendEvent({ - type: "error", - message: maxIterMsg, - errorCode: "max_iterations", - }); + async callLLMWithToolLoop(params: Parameters[0]) { + const inputBudget = getInputTokenBudget(params.model); + const orchestrator = new BaseToolLoopOrchestrator(this.deps, this.chatRepo); + return orchestrator.callLLMWithToolLoop({ ...params, inputTokenBudget: inputBudget }); } } diff --git a/src/app/service/agent/service_worker/tool_loop_orchestrator_base.ts b/src/app/service/agent/service_worker/tool_loop_orchestrator_base.ts new file mode 100644 index 000000000..053828ee1 --- /dev/null +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator_base.ts @@ -0,0 +1,766 @@ +import type { AgentChatRepo } from "@App/app/repo/agent_chat"; +import type { ScriptToolCallback, ToolExecutorLike } from "@App/app/service/agent/core/tool_registry"; +import type { + AgentModelConfig, + ChatRequest, + ChatStreamEvent, + ChatMessage, + ToolDefinition, + ToolCall, + Attachment, + SubAgentDetails, + ContentBlock, + MessageContent, + TokenUsage, +} from "@App/app/service/agent/core/types"; +import { uuidv4 } from "@App/pkg/utils/uuid"; +import { getContextWindow } from "@App/app/service/agent/core/model_context"; +import { detectToolCallIssues, type ToolCallRecord } from "@App/app/service/agent/core/tool_call_guard"; +import { + elideOldToolResults, + elideUntilWithinBudget, + estimateRequestTokens, + loadAttachmentSizes, +} from "@App/app/service/agent/core/context_elision"; +import type { LLMCallResult } from "./llm_client"; +import { t } from "@App/locales/locales"; + +// 上下文占用达到这些比例时,分批裁剪保留窗口外的旧 tool 结果(早于 autoCompact 的 80% 阈值)。 +// 按阈值分批触发,并在 60% 以上每新增一个保留窗口重新裁剪,兼顾真正的滑动窗口与 prompt cache 稳定性。 +const ELISION_THRESHOLDS = [0.4, 0.6]; +// 发送前预算检查的安全阈值:估算的下一次请求体积达到该比例时才触发裁剪/拒绝,避免与 0.9 的硬预算基准脱节 +const PREFLIGHT_BUDGET_RATIO = 0.9; +// 保留最近几轮 assistant(带 toolCalls) 及其 tool 结果的完整原文,更早的轮次被裁剪为占位文本 +const ELISION_KEEP_LAST_ASSISTANT_TURNS = 5; + +// 循环检测(tool_call_guard)连续命中达到此次数时,暂停并询问用户是否继续(仅当调用方提供 askUserForGuard 时生效) +const GUARD_ESCALATION_STRIKES = 2; +const GUARD_STOP_ANSWER = "stop"; + +type AskUserForGuard = (strikeCount: number) => Promise; + +/** + * Provider 归一化后的实际上下文输入 token。 + * Anthropic 将缓存命中/写入 token 与断点后的 input_tokens 分开返回;OpenAI 的 prompt_tokens 已含缓存部分。 + */ +function getContextInputTokens(model: AgentModelConfig, usage: NonNullable): number { + if (model.provider !== "anthropic") return usage.inputTokens; + return usage.inputTokens + (usage.cacheCreationInputTokens || 0) + (usage.cacheReadInputTokens || 0); +} + +/** 等待 loop-guard 回答;AbortSignal 触发时立即返回 null,不再阻塞会话停止/断开。 */ +function waitForGuardAnswer( + askUserForGuard: AskUserForGuard, + strikeCount: number, + signal: AbortSignal +): Promise { + if (signal.aborted) return Promise.resolve(null); + + return new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => signal.removeEventListener("abort", onAbort); + const resolveOnce = (answer: string | null) => { + if (settled) return; + settled = true; + cleanup(); + resolve(answer); + }; + const rejectOnce = (error: unknown) => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }; + const onAbort = () => resolveOnce(null); + + signal.addEventListener("abort", onAbort, { once: true }); + Promise.resolve() + .then(() => (settled || signal.aborted ? null : askUserForGuard(strikeCount))) + .then(resolveOnce, rejectOnce); + }); +} + +/** ToolLoopOrchestrator 所需的外部依赖(由 AgentService 注入) */ +export interface ToolLoopDeps { + // callLLM 通过 lambda 注入,确保测试 spy 可以拦截 + callLLM( + model: AgentModelConfig, + params: { + messages: ChatRequest["messages"]; + tools?: ToolDefinition[]; + cache?: boolean; + }, + sendEvent: (event: ChatStreamEvent) => void, + signal: AbortSignal + ): Promise; + autoCompact( + conversationId: string, + model: AgentModelConfig, + messages: ChatRequest["messages"], + sendEvent: (event: ChatStreamEvent) => void, + signal: AbortSignal + ): Promise; +} + +export class ToolLoopOrchestrator { + // 注意:不在构造器持有 toolRegistry。 + // 每次 callLLMWithToolLoop 由调用方传入(通常是 SessionToolRegistry), + // 保证并发会话各自使用独立的工具注册表,避免闭包互相覆盖。 + constructor( + private deps: ToolLoopDeps, + private chatRepo: AgentChatRepo + ) {} + + /** 上下文即使裁剪到底也无法容纳下一次请求时,落库 + 通知 UI + (可选)抛出结构化错误。 + * 落库失败不能阻塞事件发送(见 finding 5:事件投递不应依赖持久化成功); + * 若此时已被 Stop,取消优先于 context_too_large,改走统一的取消终态化路径。 */ + private async emitContextTooLarge( + conversationId: string | undefined, + totalUsage: { + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens: number; + cacheReadInputTokens: number; + }, + startTime: number, + sendEvent: (event: ChatStreamEvent) => void, + signal: AbortSignal, + throwOnTerminalError?: boolean + ): Promise { + if (signal.aborted) { + await this.emitCancelled(conversationId, totalUsage, startTime, sendEvent); + return; + } + const error = Object.assign(new Error("Conversation history exceeds the model context window"), { + errorCode: "context_too_large", + usage: totalUsage, + durationMs: Date.now() - startTime, + conversationId, + }); + if (conversationId) { + try { + await this.chatRepo.appendMessage({ + id: uuidv4(), + conversationId, + role: "assistant", + content: "", + error: error.message, + errorCode: error.errorCode, + usage: totalUsage, + durationMs: error.durationMs, + createtime: Date.now(), + }); + } catch { + // 持久化失败不阻塞终态事件发送 + } + } + // 持久化期间也可能已被 Stop:取消优先于 context_too_large,不能在 Stop 之后仍对外报告/ + // 抛出 context_too_large(见 finding 5) + if (signal.aborted) { + await this.emitCancelled(conversationId, totalUsage, startTime, sendEvent); + return; + } + sendEvent({ + type: "error", + message: error.message, + errorCode: error.errorCode, + usage: error.usage, + durationMs: error.durationMs, + }); + if (throwOnTerminalError) throw error; + } + + /** 取消(stop)落定时的统一终态化:持久化一条终态记录 + 发送唯一的终态事件,携带累计 usage/耗时。 + * 落库失败不能阻塞事件发送,否则客户端永远收不到终态事件(见 finding 5)。 */ + private async emitCancelled( + conversationId: string | undefined, + totalUsage: { + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens: number; + cacheReadInputTokens: number; + }, + startTime: number, + sendEvent: (event: ChatStreamEvent) => void + ): Promise { + const durationMs = Date.now() - startTime; + if (conversationId) { + try { + await this.chatRepo.appendMessage({ + id: uuidv4(), + conversationId, + role: "assistant", + content: "", + error: "Conversation cancelled", + errorCode: "cancelled", + usage: totalUsage, + durationMs, + createtime: Date.now(), + }); + } catch { + // 持久化失败不阻塞终态事件发送 + } + } + sendEvent({ + type: "error", + message: "Conversation cancelled", + errorCode: "cancelled", + usage: totalUsage, + durationMs, + }); + } + + // 统一的 tool calling 循环,UI 和脚本共用 + async callLLMWithToolLoop(params: { + // 本次调用使用的工具注册表(SessionToolRegistry 或 ToolRegistry) + toolRegistry: ToolExecutorLike; + model: AgentModelConfig; + inputTokenBudget?: number; + messages: ChatRequest["messages"]; + tools?: ToolDefinition[]; + maxIterations: number; + sendEvent: (event: ChatStreamEvent) => void; + signal: AbortSignal; + // 脚本自定义工具的回调,null 表示只用内置工具 + scriptToolCallback: ScriptToolCallback | null; + // 对话 ID,用于持久化消息(可选,UI 场景由 hooks 自行持久化) + conversationId?: string; + // 跳过内置工具,仅使用传入的 tools(ephemeral 模式) + skipBuiltinTools?: boolean; + // 排除的工具名称列表(子代理不可用 ask_user、agent) + excludeTools?: string[]; + // 是否启用 prompt caching,默认 true + cache?: boolean; + // 消息来自持久化历史时,先在独立副本上裁剪旧 tool 结果。 + rehydratedHistory?: boolean; + // 循环检测连续命中达到 GUARD_ESCALATION_STRIKES 次时调用,暂停循环询问用户是否继续。 + // 仅由 UI 对话(含后台会话)传入;定时任务、子代理不传,保持原有的仅告警不暂停行为。 + askUserForGuard?: AskUserForGuard; + // 定时任务和子代理需要将 max_iterations 作为失败抛给调用方。 + throwOnTerminalError?: boolean; + }): Promise { + const { + toolRegistry, + model, + inputTokenBudget, + messages: inputMessages, + tools, + maxIterations, + sendEvent, + signal, + scriptToolCallback, + conversationId, + rehydratedHistory, + throwOnTerminalError, + } = params; + const startTime = Date.now(); + const totalUsage = { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + }; + const attachmentSizes = await loadAttachmentSizes(inputMessages, (id) => this.chatRepo.getAttachment(id)); + const budgetWindow = inputTokenBudget ?? getContextWindow(model); + const preflightBudgetWindow = + inputTokenBudget === undefined ? budgetWindow : Math.max(1, inputTokenBudget / PREFLIGHT_BUDGET_RATIO); + + // 持久化历史保留完整结果供 UI 展示;LLM 只使用独立副本,续接时首个请求也先裁剪旧结果。 + const messages = rehydratedHistory ? inputMessages.map((message) => ({ ...message })) : inputMessages; + if (rehydratedHistory) { + const initialTools = params.skipBuiltinTools ? tools || [] : toolRegistry.getDefinitions(tools); + const estimatedInputTokens = estimateRequestTokens(messages, initialTools, attachmentSizes, model); + const estimatedUsageRatio = estimatedInputTokens / preflightBudgetWindow; + if (estimatedUsageRatio >= ELISION_THRESHOLDS[0]) { + const withinBudget = elideUntilWithinBudget( + messages, + preflightBudgetWindow, + initialTools, + PREFLIGHT_BUDGET_RATIO, + attachmentSizes, + model + ); + if (!withinBudget) { + await this.emitContextTooLarge( + conversationId, + totalUsage, + startTime, + sendEvent, + signal, + throwOnTerminalError + ); + return; + } + } + } + + let iterations = 0; + const toolCallHistory: ToolCallRecord[] = []; + let guardStartIndex = 0; + // 已触发过的裁剪阈值下标(-1 表示尚未触发过),避免同一阈值区间内每轮重复裁剪 + let lastElisionThresholdIndex = -1; + // 60% 以上按“每新增一个保留窗口”重新裁剪,避免窗口在第二个阈值后停止滑动 + let assistantToolTurns = 0; + let lastElisionAssistantTurn = 0; + // 循环检测命中次数,达到 GUARD_ESCALATION_STRIKES 后暂停询问用户 + let guardStrikeCount = 0; + + while (iterations < maxIterations) { + iterations++; + + // 每轮重新获取工具定义(load_skill 可能动态注册了新工具) + let allToolDefs = params.skipBuiltinTools ? tools || [] : toolRegistry.getDefinitions(tools); + if (params.excludeTools && params.excludeTools.length > 0) { + const excludeSet = new Set(params.excludeTools); + allToolDefs = allToolDefs.filter((t) => !excludeSet.has(t.name)); + } + + // 发送前预算检查:用上一轮真实 usage 判断是否需要裁剪只在响应之后生效, + // 无法覆盖“上一轮新追加的 tool 结果单独撑爆下一次请求”的情况,必须在这里对 + // 即将发出的完整请求(messages + 当前工具定义)做一次独立估算。 + const preflightTokens = estimateRequestTokens(messages, allToolDefs, attachmentSizes, model); + if (preflightTokens / preflightBudgetWindow >= PREFLIGHT_BUDGET_RATIO) { + const withinBudget = elideUntilWithinBudget( + messages, + preflightBudgetWindow, + allToolDefs, + PREFLIGHT_BUDGET_RATIO, + attachmentSizes, + model + ); + if (!withinBudget) { + await this.emitContextTooLarge( + conversationId, + totalUsage, + startTime, + sendEvent, + signal, + throwOnTerminalError + ); + return; + } + } + + // 调用 LLM(重试由 llm_client 内部处理) + let result: LLMCallResult; + try { + result = await this.deps.callLLM( + model, + { + messages, + tools: allToolDefs.length > 0 ? allToolDefs : undefined, + cache: params.cache, + }, + sendEvent, + signal + ); + } catch (error) { + // SSE 解析层现在会在 abort 时 reject(而不是静默挂起,见 content_utils.ts), + // 这类 reject 必须走统一的取消终态化路径,而不是当作真实错误往外抛 + if (signal.aborted) { + // provider 层可能已经把这一轮已知的部分 usage(如 Anthropic 的 message_start、 + // 部分 OpenAI 兼容 API 每个 chunk 都带的 usage)挂在 abort 错误上,取消前必须并入 + // totalUsage,否则这部分已经产生的花费会从终态 usage 里丢失(见 finding 6) + const partialUsage = (error as { usage?: LLMCallResult["usage"] })?.usage; + if (partialUsage) { + totalUsage.inputTokens += partialUsage.inputTokens; + totalUsage.outputTokens += partialUsage.outputTokens; + totalUsage.cacheCreationInputTokens += partialUsage.cacheCreationInputTokens || 0; + totalUsage.cacheReadInputTokens += partialUsage.cacheReadInputTokens || 0; + } + await this.emitCancelled(conversationId, totalUsage, startTime, sendEvent); + return; + } + throw Object.assign(error instanceof Error ? error : new Error(String(error)), { + usage: totalUsage, + durationMs: Date.now() - startTime, + conversationId, + }); + } + + // 先累计本轮 usage 再检查 aborted:即使取消发生在这次响应之后,其花费也不应从终态 usage 中丢失 + if (result.usage) { + totalUsage.inputTokens += result.usage.inputTokens; + totalUsage.outputTokens += result.usage.outputTokens; + totalUsage.cacheCreationInputTokens += result.usage.cacheCreationInputTokens || 0; + totalUsage.cacheReadInputTokens += result.usage.cacheReadInputTokens || 0; + } + + if (signal.aborted) { + await this.emitCancelled(conversationId, totalUsage, startTime, sendEvent); + return; + } + + // 自动 compact:当上下文占用超过 80% 时触发;否则按阈值分批裁剪旧 tool 结果(见下方 pendingElision) + let pendingElision = false; + let contextUsageRatio: number | null = null; + if (result.usage && conversationId) { + contextUsageRatio = getContextInputTokens(model, result.usage) / budgetWindow; + + if (contextUsageRatio >= 0.8) { + try { + const compactUsage = await this.deps.autoCompact(conversationId, model, messages, sendEvent, signal); + if (compactUsage) { + totalUsage.inputTokens += compactUsage.inputTokens; + totalUsage.outputTokens += compactUsage.outputTokens; + totalUsage.cacheCreationInputTokens += compactUsage.cacheCreationInputTokens || 0; + totalUsage.cacheReadInputTokens += compactUsage.cacheReadInputTokens || 0; + } + } catch (error) { + if (signal.aborted) { + await this.emitCancelled(conversationId, totalUsage, startTime, sendEvent); + return; + } + throw Object.assign(error instanceof Error ? error : new Error(String(error)), { + usage: totalUsage, + durationMs: Date.now() - startTime, + conversationId, + }); + } + // autoCompact 期间可能已被 stop:继续持久化/发送最终消息前必须重新检查, + // 并统一走取消终态化路径(唯一一条终态事件 + 已回写的累计 usage),而不是静默 return + if (signal.aborted) { + await this.emitCancelled(conversationId, totalUsage, startTime, sendEvent); + return; + } + } else { + for (let i = 0; i < ELISION_THRESHOLDS.length; i++) { + if (i > lastElisionThresholdIndex && contextUsageRatio >= ELISION_THRESHOLDS[i]) { + pendingElision = true; + lastElisionThresholdIndex = i; + } + } + } + } + + // 构建 assistant 消息的持久化内容(合并文本和生成的图片 blocks) + const buildMessageContent = (): MessageContent => { + if (result.contentBlocks && result.contentBlocks.length > 0) { + const blocks: ContentBlock[] = []; + if (result.content) blocks.push({ type: "text", text: result.content }); + blocks.push(...result.contentBlocks); + return blocks; + } + return result.content; + }; + + // 如果有 tool calls,需要执行并继续循环 + if (result.toolCalls && result.toolCalls.length > 0 && allToolDefs.length > 0) { + // 持久化 assistant 消息(含 tool calls)。保留对象引用(含 id), + // 后续回写状态时按 id 直接 updateMessage,避免再整份 getMessages+scan+saveMessages。 + let persistedAssistantMessage: ChatMessage | undefined; + if (conversationId) { + persistedAssistantMessage = { + id: uuidv4(), + conversationId, + role: "assistant", + content: buildMessageContent(), + thinking: result.thinking ? { content: result.thinking } : undefined, + toolCalls: result.toolCalls, + createtime: Date.now(), + }; + await this.chatRepo.appendMessage(persistedAssistantMessage); + } + + // 将 assistant 消息加入上下文(带 toolCalls,供 provider 构建 tool_calls 字段) + messages.push({ + role: "assistant", + content: result.content || "", + toolCalls: result.toolCalls, + }); + + // 通过 ToolRegistry 执行工具(内置工具直接执行,脚本工具回调 Sandbox) + // excludeTools 做后端强校验:被排除的工具名直接返回 error,避免 LLM 盲调绕过白/黑名单 + const excludeToolsSet = + params.excludeTools && params.excludeTools.length > 0 ? new Set(params.excludeTools) : undefined; + // 脚本工具通过 raceWithAbort 包裹:abort 时会直接 reject,而不是返回部分结果, + // 必须捕获后按"取消"处理,复用下面统一的补全逻辑,而不是让异常直接抛出跳过终态化 + let toolResults: Awaited>; + try { + toolResults = await toolRegistry.execute(result.toolCalls, scriptToolCallback, excludeToolsSet, signal); + } catch (error) { + if (!signal.aborted) throw error; + toolResults = []; + } + const cancelledDuringTools = signal.aborted; + if (cancelledDuringTools) { + // 取消发生在工具执行期间:toolRegistry 可能未及为所有 toolCalls 返回结果(如尚未触达的脚本工具), + // 在此补全为 cancelled,确保下面的回写逻辑不会把任何 toolCall 遗留在 "running" + const resultIds = new Set(toolResults.map((r) => r.id)); + for (const tc of result.toolCalls) { + if (!resultIds.has(tc.id)) { + toolResults.push({ + id: tc.id, + result: JSON.stringify({ error: "Tool execution cancelled" }), + error: true, + }); + } + } + } + + // 将 tool 结果加入消息,并通知 UI 工具执行完成 + // 收集需要回写的 toolCall 元数据(执行状态 / 附件 / 子代理详情) + const attachmentUpdates = new Map(); + const subAgentUpdates = new Map(); + const completedIds = new Set(); + const failedIds = new Set(); + + for (const tr of toolResults) { + // LLM 上下文只包含文本结果,不含附件 + messages.push({ + role: "tool", + content: tr.result, + toolCallId: tr.id, + }); + // 通知 UI 工具执行完成(含附件元数据) + sendEvent({ + type: "tool_call_complete", + id: tr.id, + result: tr.result, + status: tr.error ? "error" : "completed", + attachments: tr.attachments, + }); + + completedIds.add(tr.id); + if (tr.error) failedIds.add(tr.id); + if (tr.attachments?.length) { + attachmentUpdates.set(tr.id, tr.attachments); + } + if (tr.subAgentDetails) { + subAgentUpdates.set(tr.id, tr.subAgentDetails); + } + + // 持久化 tool 结果消息 + if (conversationId) { + await this.chatRepo.appendMessage({ + id: uuidv4(), + conversationId, + role: "tool", + content: tr.result, + toolCallId: tr.id, + createtime: Date.now(), + }); + } + } + + // 回写工具执行结果到 assistant 消息的 toolCalls(内存 + 持久化)。 + // assistant 消息在执行前已落库,其 toolCalls 的 status 仍是 "running"; + // 必须在此回写为 "completed",否则刷新/重载会从库里读回 running,导致工具图标一直转圈。 + const applyToolUpdates = (toolCalls: ToolCall[]) => { + for (const tc of toolCalls) { + if (failedIds.has(tc.id)) tc.status = "error"; + else if (completedIds.has(tc.id)) tc.status = "completed"; + const atts = attachmentUpdates.get(tc.id); + if (atts) tc.attachments = atts; + const sad = subAgentUpdates.get(tc.id); + if (sad) tc.subAgentDetails = sad; + } + }; + + // 内存上下文中的 assistant 消息:目标消息一定是刚 push 过 toolCalls 的那条, + // 从尾部往回找(本轮只追加了少量消息)比 Array.prototype.find 的正向全量扫描更快。 + // persistedAssistantMessage.toolCalls 与这里的 toolCalls 是同一个数组引用(都来自 + // result.toolCalls),因此这一次 applyToolUpdates 同时完成了内存态与待持久化对象的回写。 + let assistantMsg: (typeof messages)[number] | undefined; + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]; + if (m.role === "assistant" && m.toolCalls?.some((tc: ToolCall) => completedIds.has(tc.id))) { + assistantMsg = m; + break; + } + } + if (assistantMsg?.toolCalls) applyToolUpdates(assistantMsg.toolCalls); + + // 持久化:appendMessage 时已保留了带 id 的完整对象引用,直接按 id updateMessage, + // 不必再整份 getMessages 扫描 + saveMessages 覆写。 + if (persistedAssistantMessage?.toolCalls) { + await this.chatRepo.updateMessage(persistedAssistantMessage); + } + + // 工具调用状态已全部回写完毕,取消可以安全终态化了:只发一条终态事件,不再进入循环检测/下一轮 + if (cancelledDuringTools) { + await this.emitCancelled(conversationId, totalUsage, startTime, sendEvent); + return; + } + + // 记录工具调用历史用于模式检测 + const resultMap = new Map(toolResults.map((r) => [r.id, r])); + for (const tc of result.toolCalls) { + const tr = resultMap.get(tc.id); + toolCallHistory.push({ + name: tc.name, + args: tc.arguments, + result: tr?.result ?? "", + iteration: iterations, + }); + } + + // 工具调用模式检测:检测重复/循环模式并注入针对性提醒 + // 每次警告后推进 startIndex,避免旧记录持续触发同一条警告 + const toolCallWarning = detectToolCallIssues(toolCallHistory, guardStartIndex); + if (toolCallWarning) { + guardStartIndex = toolCallHistory.length; + messages.push({ role: "user", content: toolCallWarning }); + sendEvent({ type: "system_warning", message: toolCallWarning }); + guardStrikeCount++; + + // 连续命中达到阈值时暂停,询问用户是否继续(仅 UI 对话传入 askUserForGuard 时生效) + if (guardStrikeCount >= GUARD_ESCALATION_STRIKES && params.askUserForGuard) { + const answer = await waitForGuardAnswer(params.askUserForGuard, guardStrikeCount, signal); + if (answer === null) { + // waitForGuardAnswer 只在 signal abort 时才会返回 null,必须走取消终态化路径, + // 而不是当作正常完成发 done——否则 Stop 期间恰好卡在等待用户回答会被误报为成功 + await this.emitCancelled(conversationId, totalUsage, startTime, sendEvent); + return; + } + if (answer.trim().toLowerCase() === GUARD_STOP_ANSWER.toLowerCase()) { + const durationMs = Date.now() - startTime; + if (conversationId) { + try { + await this.chatRepo.appendMessage({ + id: uuidv4(), + conversationId, + role: "assistant", + content: t("agent:chat_guard_stopped_message"), + usage: totalUsage, + durationMs, + createtime: Date.now(), + }); + } catch { + // 持久化失败不阻塞终态事件发送 + } + } + // 持久化期间也可能已被 Stop:取消优先于 done,不能在 Stop 之后仍报告成功 + if (signal.aborted) { + await this.emitCancelled(conversationId, totalUsage, startTime, sendEvent); + return; + } + sendEvent({ type: "done", usage: totalUsage, durationMs }); + return; + } + // 用户选择继续:重置命中计数,避免此后每一次告警都重新弹出询问 + guardStrikeCount = 0; + } + } + + assistantToolTurns++; + // 60% 以上每新增一个完整保留窗口再裁剪一次,既保持窗口滑动,也避免每轮破坏缓存前缀。 + if ( + !pendingElision && + contextUsageRatio !== null && + contextUsageRatio >= ELISION_THRESHOLDS[ELISION_THRESHOLDS.length - 1] && + assistantToolTurns - lastElisionAssistantTurn >= ELISION_KEEP_LAST_ASSISTANT_TURNS + ) { + pendingElision = true; + } + + // 分批裁剪:待本轮 assistant/tool 消息入列后再裁剪,让本轮内容计入"最近 K 轮"窗口 + if (pendingElision) { + elideOldToolResults(messages, ELISION_KEEP_LAST_ASSISTANT_TURNS); + lastElisionAssistantTurn = assistantToolTurns; + } + + // 通知 UI 即将开始新一轮 LLM 调用,创建新的 assistant 消息 + sendEvent({ type: "new_message" }); + + // 继续循环 + continue; + } + + // 没有 tool calls,对话结束。与取消/错误终态不同,done 对外承诺"回复已持久化"; + // UI 完成回调会重新从 OPFS 加载消息,静默吞掉写入失败仍报 done 会让回复看起来生成成功、 + // 刷新后又消失。这里有限重试几次,仍失败则改报结构化错误而不是假装成功(见 finding 10)。 + const durationMs = Date.now() - startTime; + let persistFailed = false; + if (conversationId) { + const assistantMessage = { + id: uuidv4(), + conversationId, + role: "assistant" as const, + content: buildMessageContent(), + thinking: result.thinking ? { content: result.thinking } : undefined, + usage: totalUsage, + durationMs, + createtime: Date.now(), + }; + const MAX_PERSIST_ATTEMPTS = 3; + for (let attempt = 1; attempt <= MAX_PERSIST_ATTEMPTS; attempt++) { + try { + await this.chatRepo.appendMessage(assistantMessage); + persistFailed = false; + break; + } catch { + persistFailed = true; + if (attempt < MAX_PERSIST_ATTEMPTS) { + await new Promise((resolve) => setTimeout(resolve, 200 * attempt)); + } + } + } + } + + // 持久化期间也可能已被 stop:内容已经落库(不丢失),但终态事件必须反映取消, + // 不能在 Stop 之后仍对外报告 done(否则后台会话状态会被"成功"覆盖掉 cancelled) + if (signal.aborted) { + await this.emitCancelled(conversationId, totalUsage, startTime, sendEvent); + return; + } + + if (persistFailed) { + sendEvent({ + type: "error", + message: "Reply was generated but failed to save. It may be lost after reloading.", + errorCode: "persist_failed", + usage: totalUsage, + durationMs, + }); + return; + } + + // 发送 done 事件 + sendEvent({ type: "done", usage: totalUsage, durationMs }); + return; + } + + // 超过最大迭代次数 + const durationMs = Date.now() - startTime; + const maxIterMsg = `Tool calling loop exceeded maximum iterations (${maxIterations})`; + if (conversationId) { + try { + await this.chatRepo.appendMessage({ + id: uuidv4(), + conversationId, + role: "assistant", + content: "", + error: maxIterMsg, + errorCode: "max_iterations", + usage: totalUsage, + durationMs, + createtime: Date.now(), + }); + } catch { + // 持久化失败不阻塞终态事件发送 + } + } + // 持久化期间也可能已被 Stop:取消优先于 max_iterations + if (signal.aborted) { + await this.emitCancelled(conversationId, totalUsage, startTime, sendEvent); + return; + } + const terminalError = { + type: "error", + message: maxIterMsg, + errorCode: "max_iterations", + usage: totalUsage, + durationMs, + } as const; + sendEvent(terminalError); + if (throwOnTerminalError) { + throw Object.assign(new Error(maxIterMsg), { + errorCode: terminalError.errorCode, + usage: totalUsage, + durationMs, + conversationId, + }); + } + } +} diff --git a/src/app/service/content/gm_api/cat_agent.test.ts b/src/app/service/content/gm_api/cat_agent.test.ts index 47fce7225..319c51826 100644 --- a/src/app/service/content/gm_api/cat_agent.test.ts +++ b/src/app/service/content/gm_api/cat_agent.test.ts @@ -20,7 +20,7 @@ function mockConnect(): MessageConnect { onMessage(cb: (msg: any) => void) { setTimeout(() => { cb({ action: "event", data: { type: "content_delta", delta: "LLM reply" } }); - cb({ action: "event", data: { type: "done", usage: { inputTokens: 10, outputTokens: 5 } } }); + cb({ action: "event", data: { type: "done", usage: { inputTokens: 10, outputTokens: 5 }, durationMs: 123 } }); }, 10); }, onDisconnect() {}, @@ -30,9 +30,27 @@ function mockConnect(): MessageConnect { return conn; } -function createInstance(commands?: Record Promise>) { +function mockConnectWithSequence(events: Array<{ delayMs: number; data: any }>): MessageConnect { + return { + onMessage(cb: (msg: any) => void) { + for (const { delayMs, data } of events) { + setTimeout(() => { + cb({ action: "event", data }); + }, delayMs); + } + }, + onDisconnect() {}, + sendMessage() {}, + disconnect() {}, + }; +} + +function createInstance( + commands?: Record Promise>, + conn: MessageConnect = mockConnect() +) { const gmSendMessage = vi.fn().mockResolvedValue(undefined); - const gmConnect = vi.fn().mockResolvedValue(mockConnect()); + const gmConnect = vi.fn().mockResolvedValue(conn); const instance = new ConversationInstance( mockConversation(), @@ -83,6 +101,7 @@ describe("ConversationInstance 命令机制", () => { expect(result.command).toBeUndefined(); expect(result.content).toBe("LLM reply"); + expect(result.durationMs).toBe(123); // 应该建立了 LLM 连接 expect(gmConnect).toHaveBeenCalled(); }); @@ -116,6 +135,15 @@ describe("ConversationInstance 命令机制", () => { expect(gmConnect).not.toHaveBeenCalled(); }); + it("chatStream 成功完成时透传 durationMs", async () => { + const { instance } = createInstance(); + const stream = await instance.chatStream("你好"); + const chunks: StreamChunk[] = []; + for await (const chunk of stream) chunks.push(chunk); + + expect(chunks.find((chunk) => chunk.type === "done")?.durationMs).toBe(123); + }); + it("脚本覆盖内置 /new 命令", async () => { const customNewHandler = vi.fn().mockResolvedValue("自定义清空逻辑"); const { instance, gmSendMessage } = createInstance({ @@ -357,6 +385,218 @@ describe("ConversationInstance ephemeral 模式", () => { }); }); +// ---- tool_call_complete / new_message 事件重建测试 ---- + +// 模拟真实的一轮 tool call 往返:tool_call_start/delta → executeTools(脚本执行)→ toolResults → +// tool_call_complete(带执行结果)→ new_message(下一轮开始)→ 最终文本 → done +function mockConnectWithToolRound(toolId: string, toolName: string): MessageConnect { + let onMsgCb: (msg: any) => void = () => {}; + return { + onMessage(cb: (msg: any) => void) { + onMsgCb = cb; + setTimeout(() => { + cb({ + action: "event", + data: { type: "tool_call_start", toolCall: { id: toolId, name: toolName, arguments: "" } }, + }); + cb({ action: "event", data: { type: "tool_call_delta", id: toolId, delta: '{"a":1}' } }); + cb({ action: "executeTools", data: [{ id: toolId, name: toolName, arguments: '{"a":1}' }] }); + }, 0); + }, + onDisconnect() {}, + sendMessage(msg: any) { + if (msg.action === "toolResults") { + const result = msg.data[0]; + setTimeout(() => { + onMsgCb({ + action: "event", + data: { + type: "tool_call_complete", + id: toolId, + result: result.result, + status: result.error ? "error" : "completed", + }, + }); + onMsgCb({ action: "event", data: { type: "new_message" } }); + onMsgCb({ action: "event", data: { type: "content_delta", delta: "Final answer" } }); + onMsgCb({ + action: "event", + data: { type: "done", usage: { inputTokens: 10, outputTokens: 5 }, durationMs: 50 }, + }); + }, 0); + } + }, + disconnect() {}, + }; +} + +describe("ConversationInstance tool_call_complete / new_message 重建历史", () => { + it("chat():assistant toolCalls 应带执行结果与 completed 状态,最终回复不重复", async () => { + const handler = vi.fn().mockResolvedValue("ok"); + const gmSendMessage = vi.fn().mockResolvedValue(undefined); + const gmConnect = vi.fn().mockResolvedValue(mockConnectWithToolRound("call-1", "my_tool")); + + const instance = new ConversationInstance( + mockConversation({ modelId: "test-model" }), + gmSendMessage, + gmConnect, + "test-script-uuid", + 20, + [{ name: "my_tool", description: "d", parameters: { type: "object", properties: {} }, handler }], + undefined, + true // ephemeral + ); + + const reply = await instance.chat("使用工具"); + expect(reply.content).toBe("Final answer"); + + const messages = await instance.getMessages(); + const assistantWithTools = messages.find((m) => m.toolCalls && m.toolCalls.length > 0); + expect(assistantWithTools).toBeDefined(); + expect(assistantWithTools!.toolCalls![0]).toMatchObject({ id: "call-1", result: "ok", status: "completed" }); + + // 应有对应的 tool 角色消息 + const toolMsg = messages.find((m) => m.role === "tool" && m.toolCallId === "call-1"); + expect(toolMsg?.content).toBe("ok"); + + // 最终 assistant 内容只应出现一次,不应重复 + const finalAssistantMsgs = messages.filter((m) => m.role === "assistant" && m.content === "Final answer"); + expect(finalAssistantMsgs).toHaveLength(1); + }); + + it("chatStream():ephemeral 历史应包含带结果的 toolCalls 及对应 tool 消息", async () => { + const handler = vi.fn().mockResolvedValue("ok"); + const gmSendMessage = vi.fn().mockResolvedValue(undefined); + const gmConnect = vi.fn().mockResolvedValue(mockConnectWithToolRound("call-2", "my_tool")); + + const instance = new ConversationInstance( + mockConversation({ modelId: "test-model" }), + gmSendMessage, + gmConnect, + "test-script-uuid", + 20, + [{ name: "my_tool", description: "d", parameters: { type: "object", properties: {} }, handler }], + undefined, + true // ephemeral + ); + + const stream = await instance.chatStream("使用工具"); + const chunks: any[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + expect(chunks.some((c) => c.type === "tool_call_complete")).toBe(true); + + const messages = await instance.getMessages(); + const assistantWithTools = messages.find((m) => m.toolCalls && m.toolCalls.length > 0); + expect(assistantWithTools).toBeDefined(); + expect(assistantWithTools!.toolCalls![0]).toMatchObject({ id: "call-2", result: "ok", status: "completed" }); + + const toolMsg = messages.find((m) => m.role === "tool" && m.toolCallId === "call-2"); + expect(toolMsg?.content).toBe("ok"); + + const finalAssistantMsgs = messages.filter((m) => m.role === "assistant" && m.content === "Final answer"); + expect(finalAssistantMsgs).toHaveLength(1); + }); + + it("chatStream() 提前 break:未完成的 toolCall 不应作为无结果的悬空协议状态记入历史(finding 9)", async () => { + const gmSendMessage = vi.fn().mockResolvedValue(undefined); + // 只发 tool_call_start,永远不发 tool_call_complete,模拟消费方在工具调用完成前就 break + const gmConnect = vi.fn().mockResolvedValue( + mockConnectWithEvents([ + { type: "tool_call_start", toolCall: { id: "call-early", name: "my_tool", arguments: "" } }, + { type: "tool_call_delta", id: "call-early", delta: "{}" }, + ]) + ); + + const instance = new ConversationInstance( + mockConversation({ modelId: "test-model" }), + gmSendMessage, + gmConnect, + "test-script-uuid", + 20, + [], + undefined, + true // ephemeral + ); + + const stream = await instance.chatStream("使用工具"); + for await (const chunk of stream) { + if (chunk.type === "tool_call") break; + } + + const messages = await instance.getMessages(); + const assistantWithTools = messages.find((m) => m.toolCalls && m.toolCalls.length > 0); + expect(assistantWithTools).toBeDefined(); + // 没有收到 result 的 toolCall 必须被补成终态(而不是原样带着 status:"running"、result:undefined 记入历史) + expect(assistantWithTools!.toolCalls![0].status).not.toBe("running"); + expect(assistantWithTools!.toolCalls![0].result).toBeDefined(); + + // 必须有配对的 tool 结果消息,否则重放给 provider 时协议状态不完整 + const toolMsg = messages.find((m) => m.role === "tool" && m.toolCallId === "call-early"); + expect(toolMsg).toBeDefined(); + }); +}); + +describe("executeTools:连接 settle 后不应继续执行剩余 handler(finding 9)", () => { + it("连接在第一个 handler 执行期间断开时,第二个 handler 不应被调用", async () => { + let disconnectCb: ((isSelfDisconnected: boolean) => void) | undefined; + + const handlerA = vi.fn().mockImplementation(async () => { + // 模拟 handlerA 执行期间用户点击 Stop / 脚本工具超时:连接断开 + disconnectCb?.(false); + return "result-a"; + }); + const handlerB = vi.fn().mockResolvedValue("result-b"); + + const conn: MessageConnect = { + onMessage(cb: (msg: any) => void) { + // 与文件中其他 mock 一致:用 setTimeout(0) 异步派发,避免手动轮询回调是否已注册 + setTimeout(() => { + cb({ + action: "executeTools", + requestId: "req-1", + data: [ + { id: "call-a", name: "tool_a", arguments: "{}" }, + { id: "call-b", name: "tool_b", arguments: "{}" }, + ], + }); + }, 0); + }, + onDisconnect(cb: (isSelfDisconnected: boolean) => void) { + disconnectCb = cb; + }, + sendMessage() {}, + disconnect() {}, + }; + + const gmSendMessage = vi.fn().mockResolvedValue(undefined); + const gmConnect = vi.fn().mockResolvedValue(conn); + + const instance = new ConversationInstance( + mockConversation({ modelId: "test-model" }), + gmSendMessage, + gmConnect, + "test-script-uuid", + 20, + [ + { name: "tool_a", description: "d", parameters: { type: "object", properties: {} }, handler: handlerA }, + { name: "tool_b", description: "d", parameters: { type: "object", properties: {} }, handler: handlerB }, + ], + undefined, + true // ephemeral + ); + + // chat() 因连接断开而 reject,这正是本测试要观察的效果 + await expect(instance.chat("使用工具")).rejects.toThrow(); + + expect(handlerA).toHaveBeenCalledOnce(); + // handlerB 不应被调用:executeTools 在 handlerA 执行期间连接已 settle, + // 后续 toolCall 直接补成取消结果,不再串行往下执行(见 finding 9) + expect(handlerB).not.toHaveBeenCalled(); + }); +}); + // ---- errorCode 透传测试 ---- // 创建发送指定事件序列的 mock 连接 @@ -380,7 +620,13 @@ function mockConnectWithEvents(events: any[]): MessageConnect { describe("errorCode 透传:chat()", () => { it("error event 带 errorCode 时,reject 的 Error 应有对应 errorCode", async () => { - const errorEvent = { type: "error", message: "Rate limit exceeded", errorCode: "rate_limit" }; + const errorEvent = { + type: "error", + message: "Rate limit exceeded", + errorCode: "rate_limit", + usage: { inputTokens: 12, outputTokens: 3 }, + durationMs: 321, + }; const gmConnect = vi.fn().mockResolvedValue(mockConnectWithEvents([errorEvent])); const instance = new ConversationInstance( @@ -395,6 +641,8 @@ describe("errorCode 透传:chat()", () => { expect(err).toBeInstanceOf(Error); expect(err.message).toBe("Rate limit exceeded"); expect((err as any).errorCode).toBe("rate_limit"); + expect((err as any).usage).toEqual({ inputTokens: 12, outputTokens: 3 }); + expect((err as any).durationMs).toBe(321); }); it("error event 无 errorCode 时,errorCode 应为 undefined", async () => { @@ -442,7 +690,13 @@ describe("errorCode 透传:chatStream()", () => { // 因此不会 throw,只需检查 chunk 中的 errorCode 即可。 it("error event 带 errorCode 时,error chunk 应有对应 errorCode", async () => { - const errorEvent = { type: "error", message: "Tool timed out", errorCode: "tool_timeout" }; + const errorEvent = { + type: "error", + message: "Tool timed out", + errorCode: "tool_timeout", + usage: { inputTokens: 12, outputTokens: 3 }, + durationMs: 321, + }; const gmConnect = vi.fn().mockResolvedValue(mockConnectWithEvents([errorEvent])); const instance = new ConversationInstance( @@ -463,6 +717,8 @@ describe("errorCode 透传:chatStream()", () => { expect(errorChunk).toBeDefined(); expect(errorChunk!.error).toBe("Tool timed out"); expect((errorChunk as any).errorCode).toBe("tool_timeout"); + expect((errorChunk as any).usage).toEqual({ inputTokens: 12, outputTokens: 3 }); + expect((errorChunk as any).durationMs).toBe(321); }); it("error event 无 errorCode 时,chunk.errorCode 应为 undefined", async () => { @@ -515,3 +771,117 @@ describe("errorCode 透传:chatStream()", () => { } }); }); + +describe("ConversationInstance 子代理事件隔离", () => { + it("chat() 应忽略 subAgent 终态事件并等待父会话结束", async () => { + const subAgent = { agentId: "sa-1", description: "child task", subAgentType: "general" }; + const conn = mockConnectWithSequence([ + { delayMs: 0, data: { type: "content_delta", delta: "child text", subAgent } }, + { + delayMs: 1, + data: { + type: "error", + message: "child failed", + errorCode: "api_error", + subAgent, + }, + }, + { delayMs: 2, data: { type: "content_delta", delta: "parent text" } }, + { + delayMs: 3, + data: { type: "done", usage: { inputTokens: 11, outputTokens: 4 }, durationMs: 91 }, + }, + ]); + const { instance } = createInstance(undefined, conn); + + await expect(instance.chat("hello")).resolves.toMatchObject({ + content: "parent text", + durationMs: 91, + }); + }); + + it("chatStream() 应忽略 subAgent 终态事件并继续输出父会话内容", async () => { + const subAgent = { agentId: "sa-2", description: "child task", subAgentType: "general" }; + const conn = mockConnectWithSequence([ + { delayMs: 0, data: { type: "content_delta", delta: "child text", subAgent } }, + { + delayMs: 1, + data: { + type: "done", + usage: { inputTokens: 7, outputTokens: 2 }, + durationMs: 33, + subAgent, + }, + }, + { delayMs: 2, data: { type: "content_delta", delta: "parent text" } }, + { + delayMs: 3, + data: { type: "done", usage: { inputTokens: 11, outputTokens: 4 }, durationMs: 91 }, + }, + ]); + const { instance } = createInstance(undefined, conn); + + const chunks: StreamChunk[] = []; + const stream = await instance.chatStream("hello"); + for await (const chunk of stream) chunks.push(chunk); + + expect(chunks).toEqual([ + { type: "content_delta", content: "parent text" }, + { type: "done", usage: { inputTokens: 11, outputTokens: 4 }, durationMs: 91 }, + ]); + }); +}); + +describe("ConversationInstance tool_call_complete 结果净化", () => { + it("chat() 返回的 toolCalls 不应携带事件专属字段,且无 status 时默认 completed", async () => { + const conn = mockConnectWithSequence([ + { + delayMs: 0, + data: { type: "tool_call_start", toolCall: { id: "tc-1", name: "my_tool", arguments: "" } }, + }, + { delayMs: 1, data: { type: "tool_call_complete", id: "tc-1", result: "done" } }, + { delayMs: 2, data: { type: "done", usage: { inputTokens: 1, outputTokens: 1 }, durationMs: 12 } }, + ]); + const { instance } = createInstance(undefined, conn); + + const reply = await instance.chat("hello"); + expect(reply.toolCalls).toHaveLength(1); + expect(reply.toolCalls?.[0]).toMatchObject({ + id: "tc-1", + name: "my_tool", + arguments: "", + result: "done", + status: "completed", + }); + expect(reply.toolCalls?.[0]).not.toHaveProperty("type"); + expect(reply.toolCalls?.[0]).not.toHaveProperty("subAgent"); + }); + + it("chatStream() 返回的 tool_call_complete chunk 应只保留工具调用字段", async () => { + const conn = mockConnectWithSequence([ + { + delayMs: 0, + data: { type: "tool_call_start", toolCall: { id: "tc-2", name: "my_tool", arguments: "" } }, + }, + { delayMs: 1, data: { type: "tool_call_complete", id: "tc-2", result: "done" } }, + { delayMs: 2, data: { type: "done", usage: { inputTokens: 1, outputTokens: 1 }, durationMs: 12 } }, + ]); + const { instance } = createInstance(undefined, conn); + + const chunks: StreamChunk[] = []; + const stream = await instance.chatStream("hello"); + for await (const chunk of stream) chunks.push(chunk); + + const completionChunk = chunks.find((chunk) => chunk.type === "tool_call_complete"); + expect(completionChunk).toBeDefined(); + expect(completionChunk?.toolCall).toMatchObject({ + id: "tc-2", + name: "my_tool", + arguments: "", + result: "done", + status: "completed", + }); + expect(completionChunk?.toolCall).not.toHaveProperty("type"); + expect(completionChunk?.toolCall).not.toHaveProperty("subAgent"); + }); +}); diff --git a/src/app/service/content/gm_api/cat_agent.ts b/src/app/service/content/gm_api/cat_agent.ts index e0a484a51..409677309 100644 --- a/src/app/service/content/gm_api/cat_agent.ts +++ b/src/app/service/content/gm_api/cat_agent.ts @@ -1,624 +1,545 @@ -import GMContext from "./gm_context"; -import { uuidv4 } from "@App/pkg/utils/uuid"; +import BaseApi, { ConversationInstance as BaseConversationInstance } from "./cat_agent_base"; import type { MessageConnect } from "@Packages/message/types"; import type { ChatReply, ChatStreamEvent, - CommandHandler, ContentBlock, - Conversation, - ConversationApiRequest, - ConversationCreateOptions, - ChatOptions, - StreamChunk, + MessageContent, + StreamChunk as BaseStreamChunk, ToolCall, ToolDefinition, - ChatMessage, - MessageRole, - MessageContent, } from "@App/app/service/agent/core/types"; -import { getTextContent } from "@App/app/service/agent/core/content_utils"; - -// 对话实例,暴露给用户脚本 -// 导出供测试使用 -export class ConversationInstance { - private toolHandlers: Map) => Promise> = new Map(); - private toolDefs: ToolDefinition[] = []; - private commandHandlers: Map = new Map(); - private ephemeral: boolean; - private cache?: boolean; - private systemPrompt?: string; - private messageHistory: Array<{ - role: MessageRole; - content: MessageContent; - toolCallId?: string; - toolCalls?: ToolCall[]; - }> = []; - - private background: boolean; - - constructor( - private conv: Conversation, - private gmSendMessage: (api: string, params: any[]) => Promise, - private gmConnect: (api: string, params: any[]) => Promise, - private scriptUuid: string, - private maxIterations: number, - initialTools?: ConversationCreateOptions["tools"], - commands?: Record, - ephemeral?: boolean, - system?: string, - cache?: boolean, - background?: boolean - ) { - this.ephemeral = ephemeral || false; - this.background = background || false; - this.cache = cache; - this.systemPrompt = system; - if (initialTools) { - for (const tool of initialTools) { - this.toolHandlers.set(tool.name, tool.handler); - this.toolDefs.push({ name: tool.name, description: tool.description, parameters: tool.parameters }); - } - } - - // 注册内置 /new 命令 - this.commandHandlers.set("/new", async () => { - await this.clear(); - return "对话已清空"; - }); - - // 用户传入的 commands 覆盖内置命令 - if (commands) { - for (const [name, handler] of Object.entries(commands)) { - this.commandHandlers.set(name, handler); - } - } - } - - get id() { - return this.conv.id; - } - - get title() { - return this.conv.title; - } - - get modelId() { - return this.conv.modelId; - } - - // 发送消息并获取回复(内置 tool calling 循环) - async chat(content: MessageContent, options?: ChatOptions): Promise { - // 命令拦截(仅纯文本消息支持命令) - const textContent = getTextContent(content); - const cmdResult = await this.tryExecuteCommand(textContent); - if (cmdResult !== undefined) return cmdResult; - const { toolDefs, handlers } = this.mergeTools(options?.tools); - - // ephemeral 模式:追加 user message 到内存历史 - if (this.ephemeral) { - this.messageHistory.push({ role: "user", content }); - } - - // 通过 GM API connect 建立流式连接 - const connectParams: Record = { - conversationId: this.conv.id, - message: content, - tools: toolDefs.length > 0 ? toolDefs : undefined, - maxIterations: this.maxIterations, - scriptUuid: this.scriptUuid, - }; - - if (this.cache !== undefined) { - connectParams.cache = this.cache; - } - if (this.background) { - connectParams.background = true; - } - if (this.ephemeral) { - connectParams.ephemeral = true; - connectParams.messages = this.messageHistory; - connectParams.system = this.systemPrompt; - connectParams.modelId = this.conv.modelId; - } - - const conn = await this.gmConnect("CAT_agentConversationChat", [connectParams]); - - const reply = await this.processChat(conn, handlers); - - // ephemeral 模式:收集 assistant 响应到内存历史 - if (this.ephemeral) { - if (reply.toolCalls && reply.toolCalls.length > 0) { - this.messageHistory.push({ role: "assistant", content: reply.content, toolCalls: reply.toolCalls }); - for (const tc of reply.toolCalls) { - if (tc.result !== undefined) { - this.messageHistory.push({ role: "tool", content: tc.result, toolCallId: tc.id }); - } - } - } - this.messageHistory.push({ role: "assistant", content: reply.content }); - } - - return reply; - } - - // 流式发送消息 - async chatStream(content: MessageContent, options?: ChatOptions): Promise> { - // 命令拦截:返回单个 done chunk(仅纯文本消息支持命令) - const textContent = getTextContent(content); - const cmdResult = await this.tryExecuteCommand(textContent); - if (cmdResult !== undefined) { - return { - [Symbol.asyncIterator]() { - let yielded = false; - return { - async next(): Promise> { - if (!yielded) { - yielded = true; - return { - value: { type: "done" as const, content: getTextContent(cmdResult.content), command: true }, - done: false, - }; - } - return { value: undefined as any, done: true }; - }, - }; - }, +export type ConversationStreamChunk = + | BaseStreamChunk + | { + type: "sync"; + streamingMessage?: { + content: string; + thinking?: string; + toolCalls: ToolCall[]; }; - } - - const { toolDefs, handlers } = this.mergeTools(options?.tools); - - // ephemeral 模式:追加 user message 到内存历史 - if (this.ephemeral) { - this.messageHistory.push({ role: "user", content }); - } - - const connectParams: Record = { - conversationId: this.conv.id, - message: content, - tools: toolDefs.length > 0 ? toolDefs : undefined, - maxIterations: this.maxIterations, - scriptUuid: this.scriptUuid, + pendingAskUser?: { + id: string; + question: string; + options?: string[]; + optionValues?: string[]; + multiple?: boolean; + allowCustom?: boolean; + }; + tasks: Array<{ + id: string; + subject: string; + status: "pending" | "in_progress" | "completed"; + description?: string; + }>; + status: "running" | "done" | "error"; }; - if (this.cache !== undefined) { - connectParams.cache = this.cache; - } - if (this.background) { - connectParams.background = true; - } - if (this.ephemeral) { - connectParams.ephemeral = true; - connectParams.messages = this.messageHistory; - connectParams.system = this.systemPrompt; - connectParams.modelId = this.conv.modelId; - } - - const conn = await this.gmConnect("CAT_agentConversationChat", [connectParams]); - - // ephemeral 模式:包装 stream 以收集 assistant 消息到内存历史 - if (this.ephemeral) { - return this.processStreamEphemeral(conn, handlers); - } - - return this.processStream(conn, handlers); - } +type ToolHandler = (args: Record) => Promise; +type Instance = BaseConversationInstance & Record; - // 解析命令:"/command args" -> { name, args } - private parseCommand(content: string): { name: string; args: string } | null { - const trimmed = content.trim(); - if (!trimmed.startsWith("/")) return null; - const spaceIdx = trimmed.indexOf(" "); - if (spaceIdx === -1) return { name: trimmed, args: "" }; - return { name: trimmed.slice(0, spaceIdx), args: trimmed.slice(spaceIdx + 1).trim() }; - } +function cloneToolCall(toolCall: ToolCall): ToolCall { + return { + ...toolCall, + attachments: toolCall.attachments ? [...toolCall.attachments] : undefined, + }; +} - // 尝试执行命令,未注册的命令返回 undefined(正常发送给 LLM) - private async tryExecuteCommand(content: string): Promise { - const parsed = this.parseCommand(content); - if (!parsed) return undefined; +function stringifyToolResult(result: unknown): string { + if (typeof result === "string") return result; + const serialized = JSON.stringify(result); + return serialized === undefined ? "null" : serialized; +} - const handler = this.commandHandlers.get(parsed.name); - if (!handler) return undefined; +function buildContent(text: string, blocks: ContentBlock[]): MessageContent { + if (blocks.length === 0) return text; + return [...(text ? [{ type: "text" as const, text }] : []), ...blocks]; +} - const result = await handler(parsed.args, this); - // 命令结果始终为纯文本 string - return { content: (result || "") as string, command: true }; +function resolveToolCall( + ordered: ToolCall[], + byId: Map, + id: string, + index?: number +): ToolCall | undefined { + if (id && byId.has(id)) return byId.get(id); + if (index !== undefined && ordered[index]) return ordered[index]; + for (let i = ordered.length - 1; i >= 0; i--) { + if ((ordered[i].status ?? "running") === "running") return ordered[i]; } + return undefined; +} - // 合并实例级别和调用级别的工具定义 - private mergeTools(callTools?: ChatOptions["tools"]) { - const toolDefs: ToolDefinition[] = [...this.toolDefs]; - const handlers = new Map(this.toolHandlers); - - if (callTools) { - for (const tool of callTools) { - // 调用级别的工具覆盖实例级别的同名工具 - if (!handlers.has(tool.name)) { - toolDefs.push({ name: tool.name, description: tool.description, parameters: tool.parameters }); - } - handlers.set(tool.name, tool.handler); - } +async function executeTools( + this: Instance, + toolCalls: ToolCall[], + handlers: Map, + // 连接/请求批次是否已经 settle(Stop、脚本工具超时、连接断开)。串行执行期间在每个 + // handler 之前检查,而不是只在整批结束后检查一次——否则已经 settle 之后,剩余的 + // handler 仍会继续跑,其副作用可能和后续新批次的 handler 重叠(见 finding 9) + isSettled?: () => boolean +): Promise> { + const results: Array<{ id: string; result: string; error?: boolean }> = []; + for (const toolCall of toolCalls) { + if (isSettled?.()) { + results.push({ + id: toolCall.id, + result: JSON.stringify({ error: "Tool execution cancelled: connection already settled" }), + error: true, + }); + continue; } - - return { toolDefs, handlers }; - } - - // 获取对话历史 - async getMessages(): Promise { - if (this.ephemeral) { - // ephemeral 模式:从内存历史转换为 ChatMessage 格式 - return this.messageHistory.map((msg, idx) => ({ - id: `ephemeral-${idx}`, - conversationId: this.conv.id, - role: msg.role, - content: msg.content, - toolCallId: msg.toolCallId, - toolCalls: msg.toolCalls, - createtime: Date.now(), - })); + const handler = handlers.get(toolCall.name); + if (!handler) { + results.push({ + id: toolCall.id, + result: JSON.stringify({ error: `Tool "${toolCall.name}" not found` }), + error: true, + }); + continue; } - const messages = await this.gmSendMessage("CAT_agentConversation", [ - { - action: "getMessages", - conversationId: this.conv.id, - scriptUuid: this.scriptUuid, - } as ConversationApiRequest, - ]); - return messages || []; - } - - // 清空对话消息历史 - async clear(): Promise { - if (this.ephemeral) { - this.messageHistory = []; - return; + try { + const args = toolCall.arguments ? JSON.parse(toolCall.arguments) : {}; + results.push({ + id: toolCall.id, + result: stringifyToolResult(await handler(args)), + }); + } catch (error) { + const message = error instanceof Error ? error.message || error.toString() : String(error); + results.push({ + id: toolCall.id, + result: JSON.stringify({ error: message }), + error: true, + }); } - await this.gmSendMessage("CAT_agentConversation", [ - { - action: "clearMessages", - conversationId: this.conv.id, - scriptUuid: this.scriptUuid, - } as ConversationApiRequest, - ]); - } - - // 持久化对话 - async save(): Promise { - await this.gmSendMessage("CAT_agentConversation", [ - { - action: "save", - conversationId: this.conv.id, - scriptUuid: this.scriptUuid, - } as ConversationApiRequest, - ]); } + return results; +} - // 附加到后台运行中的会话,返回流式事件 - async attach(): Promise> { - const conn = await this.gmConnect("CAT_agentAttachToConversation", [ - { conversationId: this.conv.id, scriptUuid: this.scriptUuid }, - ]); - return this.processStream(conn, new Map()); +function mergeTools(this: Instance, callTools?: Array) { + const toolDefs: ToolDefinition[] = [...this.toolDefs]; + const handlers = new Map(this.toolHandlers); + for (const tool of callTools || []) { + const definition = { + name: tool.name, + description: tool.description, + parameters: tool.parameters, + }; + const index = toolDefs.findIndex((item) => item.name === tool.name); + if (index >= 0) toolDefs[index] = definition; + else toolDefs.push(definition); + handlers.set(tool.name, tool.handler); } + return { toolDefs, handlers }; +} - // 处理非流式 chat 的响应 - private processChat( - conn: MessageConnect, - handlers: Map) => Promise> - ): Promise { - return new Promise((resolve, reject) => { - let content = ""; - let thinking = ""; - const toolCalls: ToolCall[] = []; - const contentBlocks: ContentBlock[] = []; - let currentToolCall: ToolCall | null = null; - let usage: { inputTokens: number; outputTokens: number } | undefined; - - conn.onMessage(async (msg: any) => { - if (msg.action === "executeTools") { - // Service Worker 请求执行 tools - const requestedToolCalls: ToolCall[] = msg.data; - const results = await this.executeTools(requestedToolCalls, handlers); - conn.sendMessage({ action: "toolResults", data: results }); - return; - } - - if (msg.action !== "event") return; - const event: ChatStreamEvent = msg.data; - - switch (event.type) { - case "content_delta": - content += event.delta; - break; - case "thinking_delta": - thinking += event.delta; - break; - case "content_block_complete": - // 收集模型生成的图片/文件/音频 blocks(data 已由 finalize 保存到 attachment 存储) - contentBlocks.push(event.block); - break; - case "tool_call_start": - if (currentToolCall) toolCalls.push(currentToolCall); - currentToolCall = { ...event.toolCall, arguments: event.toolCall.arguments || "" }; - break; - case "tool_call_delta": - if (currentToolCall) currentToolCall.arguments += event.delta; - break; - case "done": { - if (currentToolCall) { - toolCalls.push(currentToolCall); - currentToolCall = null; - } - if (event.usage) usage = event.usage; - // 合并文本和 content blocks 到 MessageContent - let finalContent: MessageContent = content; - if (contentBlocks.length > 0) { - const blocks: ContentBlock[] = []; - if (content) blocks.push({ type: "text", text: content }); - blocks.push(...contentBlocks); - finalContent = blocks; - } - resolve({ - content: finalContent, - thinking: thinking || undefined, - toolCalls: toolCalls.length > 0 ? toolCalls : undefined, - usage, +function processChat(this: Instance, conn: MessageConnect, handlers: Map): Promise { + return new Promise((resolve, reject) => { + // 部分实现的 disconnect() 会同步触发 onDisconnect,若在 resolve/reject 之前调用会被 + // "Connection disconnected" 抢先 reject;用 settled 标记确保只有第一次终态判定生效。 + let settled = false; + let content = ""; + let thinking = ""; + let blocks: ContentBlock[] = []; + let ordered: ToolCall[] = []; + let byId = new Map(); + const aggregate: ToolCall[] = []; + let usage: ChatReply["usage"]; + let durationMs: number | undefined; + + const finishRound = (record = true): MessageContent => { + const finalContent = buildContent(content, blocks); + const round = ordered.map(cloneToolCall); + aggregate.push(...round); + if (this.ephemeral && record && (content || blocks.length || round.length)) { + this.messageHistory.push({ + role: "assistant", + content: finalContent, + toolCalls: round.length ? round : undefined, + }); + for (const toolCall of round) { + if (toolCall.result !== undefined) { + this.messageHistory.push({ + role: "tool", + content: toolCall.result, + toolCallId: toolCall.id, }); - break; } - case "error": - reject(Object.assign(new Error(event.message), { errorCode: event.errorCode })); - break; } - }); - - conn.onDisconnect(() => { - reject(new Error("Connection disconnected")); - }); - }); - } - - // 处理流式 chat 的响应 - private processStream( - conn: MessageConnect, - handlers: Map) => Promise> - ): AsyncIterable { - const chunks: StreamChunk[] = []; - let resolve: (() => void) | null = null; - let done = false; - let error: Error | null = null; + } + content = ""; + blocks = []; + ordered = []; + byId = new Map(); + return finalContent; + }; - conn.onMessage(async (msg: any) => { - if (msg.action === "executeTools") { - const requestedToolCalls: ToolCall[] = msg.data; - const results = await this.executeTools(requestedToolCalls, handlers); - conn.sendMessage({ action: "toolResults", data: results }); + conn.onMessage(async (message: any) => { + if (message.action === "executeTools") { + const data = await executeTools.call(this, message.data, handlers, () => settled); + // 工具函数执行期间连接可能已经因 Stop/脚本工具超时而 settle 并断开; + // 断开后的连接 sendMessage 会抛错,且这里是异步回调,事件源不会 await/捕获它, + // 会在用户脚本上下文里变成 unhandled rejection(见 finding 7) + if (settled) return; + try { + conn.sendMessage({ + action: "toolResults", + requestId: message.requestId, + data, + }); + } catch { + // 连接已断开,结果无处可送,安全忽略 + } return; } - - if (msg.action !== "event") return; - const event: ChatStreamEvent = msg.data; - - let chunk: StreamChunk | null = null; + if (message.action !== "event") return; + const event: ChatStreamEvent = message.data; + if ("subAgent" in event && event.subAgent) return; switch (event.type) { case "content_delta": - chunk = { type: "content_delta", content: event.delta }; + content += event.delta; break; case "thinking_delta": - chunk = { type: "thinking_delta", content: event.delta }; + thinking += event.delta; break; case "content_block_complete": - chunk = { type: "content_block", block: event.block }; + blocks.push(event.block); + break; + case "tool_call_start": { + const toolCall: ToolCall = { + ...event.toolCall, + arguments: event.toolCall.arguments || "", + status: "running", + }; + ordered.push(toolCall); + byId.set(toolCall.id, toolCall); + break; + } + case "tool_call_delta": { + const toolCall = resolveToolCall(ordered, byId, event.id, event.index); + if (toolCall) toolCall.arguments += event.delta; + break; + } + case "tool_call_complete": { + const toolCall = resolveToolCall(ordered, byId, event.id); + if (toolCall) { + toolCall.result = event.result; + toolCall.status = event.status ?? "completed"; + toolCall.attachments = event.attachments ? [...event.attachments] : undefined; + } break; - case "tool_call_start": - chunk = { type: "tool_call", toolCall: { ...event.toolCall, arguments: "" } }; + } + case "new_message": + finishRound(); break; case "done": - chunk = { type: "done", usage: event.usage }; - done = true; + usage = event.usage; + durationMs = event.durationMs; + settled = true; + resolve({ + content: finishRound(false), + thinking: thinking || undefined, + toolCalls: aggregate.length ? aggregate : undefined, + usage, + durationMs, + }); + conn.disconnect(); break; case "error": - chunk = { type: "error", error: event.message, errorCode: event.errorCode }; - error = Object.assign(new Error(event.message), { errorCode: event.errorCode }); - done = true; + settled = true; + reject(Object.assign(new Error(event.message), event)); + conn.disconnect(); break; } - - if (chunk) { - chunks.push(chunk); - resolve?.(); - } }); - conn.onDisconnect(() => { - done = true; - error = error || new Error("Connection disconnected"); - resolve?.(); + if (settled) return; + settled = true; + reject(new Error("Connection disconnected")); }); + }); +} - return { - [Symbol.asyncIterator]() { - return { - async next(): Promise> { - while (chunks.length === 0 && !done) { - await new Promise((r) => { - resolve = r; - }); - } - - if (chunks.length > 0) { - return { value: chunks.shift()!, done: false }; - } - - if (error && !done) throw error; - return { value: undefined as any, done: true }; - }, - }; - }, - }; - } - - // 处理 ephemeral 流式 chat 的响应(收集 assistant 消息到内存历史) - private processStreamEphemeral( - conn: MessageConnect, - handlers: Map) => Promise> - ): AsyncIterable { - const inner = this.processStream(conn, handlers); - const messageHistory = this.messageHistory; - let content = ""; - const toolCalls: ToolCall[] = []; - - return { - [Symbol.asyncIterator]() { - const iter = inner[Symbol.asyncIterator](); - return { - async next(): Promise> { - const result = await iter.next(); - if (result.done) { - // 流结束时,追加 assistant 消息到历史 - if (content || toolCalls.length > 0) { - messageHistory.push({ - role: "assistant", - content, - toolCalls: toolCalls.length > 0 ? [...toolCalls] : undefined, - }); +function processStream( + this: Instance, + conn: MessageConnect, + handlers: Map +): AsyncIterable { + const chunks: ConversationStreamChunk[] = []; + let wake: (() => void) | undefined; + let done = false; + let error: Error | undefined; + let surfaced = false; + let ordered: ToolCall[] = []; + let byId = new Map(); + + const reset = (toolCalls: ToolCall[] = []) => { + ordered = toolCalls.map((toolCall) => ({ + ...cloneToolCall(toolCall), + status: toolCall.status ?? "running", + })); + byId = new Map(ordered.map((toolCall) => [toolCall.id, toolCall])); + }; + const push = (chunk: ConversationStreamChunk) => { + chunks.push(chunk); + wake?.(); + }; + + conn.onMessage(async (message: any) => { + if (message.action === "executeTools") { + const data = await executeTools.call(this, message.data, handlers, () => done); + // 工具函数执行期间连接可能已经因 Stop/脚本工具超时而结束并断开; + // 断开后的连接 sendMessage 会抛错,且这里是异步回调,事件源不会 await/捕获它, + // 会在用户脚本上下文里变成 unhandled rejection(见 finding 7) + if (done) return; + try { + conn.sendMessage({ + action: "toolResults", + requestId: message.requestId, + data, + }); + } catch { + // 连接已断开,结果无处可送,安全忽略 + } + return; + } + if (message.action !== "event") return; + const event: ChatStreamEvent = message.data; + if ("subAgent" in event && event.subAgent) return; + switch (event.type) { + case "sync": + reset(event.streamingMessage?.toolCalls || []); + push({ + type: "sync", + streamingMessage: event.streamingMessage + ? { + ...event.streamingMessage, + toolCalls: ordered.map(cloneToolCall), } - return result; - } - - const chunk = result.value; - switch (chunk.type) { - case "content_delta": - content += chunk.content || ""; - break; - case "tool_call": - if (chunk.toolCall) { - toolCalls.push(chunk.toolCall); - } - break; - } - return result; - }, + : undefined, + pendingAskUser: event.pendingAskUser ? { ...event.pendingAskUser } : undefined, + tasks: event.tasks.map((task) => ({ ...task })), + status: event.status, + }); + done = event.status !== "running"; + // 终态快照:attach 的会话已经结束,SW 侧不会再为这条连接注册 listener, + // 也就不会再有后续事件——必须在这里主动断开,否则 port 会一直挂着 + if (done) conn.disconnect(); + break; + case "content_delta": + push({ type: "content_delta", content: event.delta }); + break; + case "thinking_delta": + push({ type: "thinking_delta", content: event.delta }); + break; + case "content_block_complete": + push({ type: "content_block", block: event.block }); + break; + case "tool_call_start": { + const toolCall: ToolCall = { + ...event.toolCall, + arguments: event.toolCall.arguments || "", + status: "running", }; - }, - }; - } - - // 执行用户定义的 tool handlers - private async executeTools( - toolCalls: ToolCall[], - handlers: Map) => Promise> - ): Promise> { - const results: Array<{ id: string; result: string }> = []; - - for (const tc of toolCalls) { - const handler = handlers.get(tc.name); - if (!handler) { - results.push({ id: tc.id, result: JSON.stringify({ error: `Tool "${tc.name}" not found` }) }); - continue; + ordered.push(toolCall); + byId.set(toolCall.id, toolCall); + push({ type: "tool_call", toolCall: cloneToolCall(toolCall) }); + break; } - - try { - let args: Record = {}; - if (tc.arguments) { - args = JSON.parse(tc.arguments); + case "tool_call_delta": { + const toolCall = resolveToolCall(ordered, byId, event.id, event.index); + if (toolCall) { + toolCall.arguments += event.delta; + push({ type: "tool_call", toolCall: cloneToolCall(toolCall) }); } - const result = await handler(args); - results.push({ id: tc.id, result: typeof result === "string" ? result : JSON.stringify(result) }); - } catch (e: any) { - const errorMsg = - e instanceof Error - ? e.message || e.toString() - : typeof e === "string" - ? e - : String(e) || "Tool execution failed"; - results.push({ id: tc.id, result: JSON.stringify({ error: errorMsg }) }); + break; } + case "tool_call_complete": { + const toolCall = resolveToolCall(ordered, byId, event.id); + if (toolCall) { + toolCall.result = event.result; + toolCall.status = event.status ?? "completed"; + toolCall.attachments = event.attachments ? [...event.attachments] : undefined; + } + push({ + type: "tool_call_complete", + toolCall: + toolCall || + ({ + id: event.id, + name: "", + arguments: "", + result: event.result, + status: event.status ?? "completed", + attachments: event.attachments ? [...event.attachments] : undefined, + } as ToolCall), + }); + break; + } + case "new_message": + push({ type: "new_message" }); + reset(); + break; + case "done": + push({ + type: "done", + usage: event.usage, + durationMs: event.durationMs, + }); + done = true; + conn.disconnect(); + break; + case "error": + push({ + type: "error", + error: event.message, + errorCode: event.errorCode, + usage: event.usage, + durationMs: event.durationMs, + }); + error = Object.assign(new Error(event.message), event); + done = true; + conn.disconnect(); + break; } + }); + conn.onDisconnect(() => { + if (done) return; + done = true; + error = new Error("Connection disconnected"); + wake?.(); + }); + + // 提前退出(for await...break、消费方 throw)时必须断开连接并唤醒挂起的 next(), + // 否则 port/listener 会一直挂在 SW 侧,直到脚本上下文销毁 + const closeEarly = () => { + if (done) return; + done = true; + try { + conn.disconnect(); + } catch { + // port 可能已断开 + } + wake?.(); + }; - return results; - } -} - -// 运行时 this 是 GM_Base 实例,定义其实际拥有的字段类型 -interface GMBaseContext { - sendMessage: (api: string, params: unknown[]) => Promise; - connect: (api: string, params: unknown[]) => Promise; - scriptRes?: { uuid: string }; -} - -// 构建 ConversationInstance,独立函数避免 this 绑定问题 -// (装饰器方法运行时 this 是 GM_Base 实例,不是 CATAgentApi) -function buildInstance( - ctx: GMBaseContext, - conv: Conversation, - options?: ConversationCreateOptions -): ConversationInstance { - return new ConversationInstance( - conv, - ctx.sendMessage.bind(ctx), - ctx.connect.bind(ctx), - ctx.scriptRes?.uuid || "", - options?.maxIterations || 20, - options?.tools, - options?.commands, - options?.ephemeral, - options?.system, - options?.cache, - options?.background - ); + return { + [Symbol.asyncIterator]() { + return { + async next(): Promise> { + while (!chunks.length && !done) await new Promise((resolve) => (wake = resolve)); + if (chunks.length) { + const chunk = chunks.shift()!; + if (chunk.type === "error") surfaced = true; + return { value: chunk, done: false }; + } + if (error && !surfaced) { + surfaced = true; + throw error; + } + return { value: undefined as never, done: true }; + }, + async return(value?: unknown): Promise> { + closeEarly(); + return { value: value as never, done: true }; + }, + async throw(err?: unknown): Promise> { + closeEarly(); + throw err; + }, + }; + }, + }; } -// CAT.agent.conversation API 对象,注入到脚本上下文 -// 使用 @GMContext.API 装饰器注册到 "CAT.agent.conversation" grant -export default class CATAgentApi { - // 标记为 protected 的内部状态(由 GM_Base 绑定) - @GMContext.protected() - protected sendMessage!: (api: string, params: any[]) => Promise; - - @GMContext.protected() - protected connect!: (api: string, params: any[]) => Promise; - - @GMContext.protected() - protected scriptRes?: any; - - // CAT.agent.conversation.create() - @GMContext.API({ follow: "CAT.agent.conversation" }) - public "CAT.agent.conversation.create"(options: ConversationCreateOptions = {}): Promise { - return (async () => { - if (options.ephemeral) { - // ephemeral 模式:不发请求到 SW,直接在脚本端构造 - const conv: Conversation = { - id: options.id || uuidv4(), - title: "New Chat", - modelId: options.model || "", - system: options.system, - createtime: Date.now(), - updatetime: Date.now(), - }; - return buildInstance(this as unknown as GMBaseContext, conv, options); +function processStreamEphemeral( + this: Instance, + conn: MessageConnect, + handlers: Map +): AsyncIterable { + const inner = processStream.call(this, conn, handlers); + let text = ""; + let blocks: ContentBlock[] = []; + let toolCalls: ToolCall[] = []; + const finish = () => { + if (text || blocks.length || toolCalls.length) { + // 提前退出(for await...break / 消费方抛错)时,可能有 toolCall 还停在 tool_call_start/ + // delta 阶段就被 return()/throw() 打断,从未收到 tool_call_complete。这类 toolCall 没有 + // result,如果原样把它们的 assistant 消息记入历史重放给 provider,大多数 provider 会 + // 因为"assistant 消息里的 tool_call 缺少对应的 tool 结果消息"而报错(见 finding 9)。 + // 统一在这里把没有 result 的 toolCall 补成终态 cancelled,并补上配对的 tool 结果消息, + // 保证重放给 provider 的历史里 tool_call/tool_result 协议状态始终完整。 + const finalized = toolCalls.map((toolCall) => { + if (toolCall.result !== undefined) return cloneToolCall(toolCall); + return cloneToolCall({ + ...toolCall, + status: "error", + result: JSON.stringify({ error: "Tool call cancelled: stream ended before it completed" }), + }); + }); + this.messageHistory.push({ + role: "assistant", + content: buildContent(text, blocks), + toolCalls: finalized.length ? finalized : undefined, + }); + for (const toolCall of finalized) { + this.messageHistory.push({ + role: "tool", + content: toolCall.result!, + toolCallId: toolCall.id, + }); } + } + text = ""; + blocks = []; + toolCalls = []; + }; + return { + [Symbol.asyncIterator]() { + const iterator = inner[Symbol.asyncIterator](); + return { + async next() { + const result = await iterator.next(); + if (result.done) { + finish(); + return result; + } + const chunk = result.value; + if (chunk.type === "content_delta") text += chunk.content || ""; + else if (chunk.type === "content_block" && chunk.block) blocks.push(chunk.block); + else if ((chunk.type === "tool_call" || chunk.type === "tool_call_complete") && chunk.toolCall) { + const index = toolCalls.findIndex((toolCall) => toolCall.id === chunk.toolCall!.id); + if (index >= 0) toolCalls[index] = cloneToolCall(chunk.toolCall); + else toolCalls.push(cloneToolCall(chunk.toolCall)); + } else if (chunk.type === "new_message") finish(); + return result; + }, + // 转发 return()/throw() 给内层 processStream 的迭代器,否则 for await...break + // 或消费方抛错时内层不会 disconnect,port 会一直挂着(见 finding 6)。 + // 提前退出时也提交已累积的部分输出到 messageHistory,与正常完成时的行为一致, + // 避免下一轮 chat() 因为丢失这部分历史而导致上下文断裂。 + async return(value?: unknown) { + finish(); + await iterator.return?.(value); + return { value: value as never, done: true }; + }, + async throw(err?: unknown) { + finish(); + await iterator.throw?.(err); + throw err; + }, + }; + }, + }; +} - const { tools: _tools, ephemeral: _ephemeral, ...serverOptions } = options; - const conv = (await this.sendMessage("CAT_agentConversation", [ - { action: "create", options: serverOptions, scriptUuid: this.scriptRes?.uuid || "" } as ConversationApiRequest, - ])) as Conversation; - return buildInstance(this as unknown as GMBaseContext, conv, options); - })(); - } +const prototype = BaseConversationInstance.prototype as unknown as Record; +prototype.mergeTools = mergeTools; +prototype.executeTools = executeTools; +prototype.processChat = processChat; +prototype.processStream = processStream; +prototype.processStreamEphemeral = processStreamEphemeral; - // CAT.agent.conversation.get() - @GMContext.API({ follow: "CAT.agent.conversation" }) - public "CAT.agent.conversation.get"(id: string): Promise { - return (async () => { - const conv = (await this.sendMessage("CAT_agentConversation", [ - { action: "get", id, scriptUuid: this.scriptRes?.uuid || "" } as ConversationApiRequest, - ])) as Conversation | null; - if (!conv) return null; - return buildInstance(this as unknown as GMBaseContext, conv); - })(); - } -} +export const ConversationInstance = BaseConversationInstance; +export default BaseApi; diff --git a/src/app/service/content/gm_api/cat_agent_base.ts b/src/app/service/content/gm_api/cat_agent_base.ts new file mode 100644 index 000000000..d85f68e3e --- /dev/null +++ b/src/app/service/content/gm_api/cat_agent_base.ts @@ -0,0 +1,730 @@ +import GMContext from "./gm_context"; +import { uuidv4 } from "@App/pkg/utils/uuid"; +import type { MessageConnect } from "@Packages/message/types"; +import type { + ChatReply, + ChatStreamEvent, + CommandHandler, + ContentBlock, + Conversation, + ConversationApiRequest, + ConversationCreateOptions, + ChatOptions, + StreamChunk, + ToolCall, + ToolDefinition, + ChatMessage, + MessageRole, + MessageContent, +} from "@App/app/service/agent/core/types"; +import { getTextContent } from "@App/app/service/agent/core/content_utils"; + +// 对话实例,暴露给用户脚本 +// 导出供测试使用 +export class ConversationInstance { + public toolHandlers: Map) => Promise> = new Map(); + public toolDefs: ToolDefinition[] = []; + private commandHandlers: Map = new Map(); + public ephemeral: boolean; + private cache?: boolean; + private systemPrompt?: string; + public messageHistory: Array<{ + role: MessageRole; + content: MessageContent; + toolCallId?: string; + toolCalls?: ToolCall[]; + }> = []; + + private background: boolean; + + constructor( + private conv: Conversation, + private gmSendMessage: (api: string, params: any[]) => Promise, + private gmConnect: (api: string, params: any[]) => Promise, + private scriptUuid: string, + private maxIterations: number, + initialTools?: ConversationCreateOptions["tools"], + commands?: Record, + ephemeral?: boolean, + system?: string, + cache?: boolean, + background?: boolean + ) { + this.ephemeral = ephemeral || false; + this.background = background || false; + this.cache = cache; + this.systemPrompt = system; + if (initialTools) { + for (const tool of initialTools) { + this.toolHandlers.set(tool.name, tool.handler); + this.toolDefs.push({ name: tool.name, description: tool.description, parameters: tool.parameters }); + } + } + + // 注册内置 /new 命令 + this.commandHandlers.set("/new", async () => { + await this.clear(); + return "对话已清空"; + }); + + // 用户传入的 commands 覆盖内置命令 + if (commands) { + for (const [name, handler] of Object.entries(commands)) { + this.commandHandlers.set(name, handler); + } + } + } + + get id() { + return this.conv.id; + } + + get title() { + return this.conv.title; + } + + get modelId() { + return this.conv.modelId; + } + + // 发送消息并获取回复(内置 tool calling 循环) + async chat(content: MessageContent, options?: ChatOptions): Promise { + // 命令拦截(仅纯文本消息支持命令) + const textContent = getTextContent(content); + const cmdResult = await this.tryExecuteCommand(textContent); + if (cmdResult !== undefined) return cmdResult; + + const { toolDefs, handlers } = this.mergeTools(options?.tools); + + // ephemeral 模式:追加 user message 到内存历史 + if (this.ephemeral) { + this.messageHistory.push({ role: "user", content }); + } + + // 通过 GM API connect 建立流式连接 + const connectParams: Record = { + conversationId: this.conv.id, + message: content, + tools: toolDefs.length > 0 ? toolDefs : undefined, + maxIterations: this.maxIterations, + scriptUuid: this.scriptUuid, + }; + + if (this.cache !== undefined) { + connectParams.cache = this.cache; + } + if (this.background) { + connectParams.background = true; + } + if (this.ephemeral) { + connectParams.ephemeral = true; + connectParams.messages = this.messageHistory; + connectParams.system = this.systemPrompt; + connectParams.modelId = this.conv.modelId; + } + + const conn = await this.gmConnect("CAT_agentConversationChat", [connectParams]); + + const reply = await this.processChat(conn, handlers); + + // ephemeral 模式:中间轮次(带 tool calls)已在 processChat 内按 new_message 边界追加到内存历史, + // 这里只需追加不含 tool calls 的最终回复(done 事件保证到达时已无待处理的 tool calls)。 + if (this.ephemeral) { + this.messageHistory.push({ role: "assistant", content: reply.content }); + } + + return reply; + } + + // 流式发送消息 + async chatStream(content: MessageContent, options?: ChatOptions): Promise> { + // 命令拦截:返回单个 done chunk(仅纯文本消息支持命令) + const textContent = getTextContent(content); + const cmdResult = await this.tryExecuteCommand(textContent); + if (cmdResult !== undefined) { + return { + [Symbol.asyncIterator]() { + let yielded = false; + return { + async next(): Promise> { + if (!yielded) { + yielded = true; + return { + value: { type: "done" as const, content: getTextContent(cmdResult.content), command: true }, + done: false, + }; + } + return { value: undefined as any, done: true }; + }, + }; + }, + }; + } + + const { toolDefs, handlers } = this.mergeTools(options?.tools); + + // ephemeral 模式:追加 user message 到内存历史 + if (this.ephemeral) { + this.messageHistory.push({ role: "user", content }); + } + + const connectParams: Record = { + conversationId: this.conv.id, + message: content, + tools: toolDefs.length > 0 ? toolDefs : undefined, + maxIterations: this.maxIterations, + scriptUuid: this.scriptUuid, + }; + + if (this.cache !== undefined) { + connectParams.cache = this.cache; + } + if (this.background) { + connectParams.background = true; + } + if (this.ephemeral) { + connectParams.ephemeral = true; + connectParams.messages = this.messageHistory; + connectParams.system = this.systemPrompt; + connectParams.modelId = this.conv.modelId; + } + + const conn = await this.gmConnect("CAT_agentConversationChat", [connectParams]); + + // ephemeral 模式:包装 stream 以收集 assistant 消息到内存历史 + if (this.ephemeral) { + return this.processStreamEphemeral(conn, handlers); + } + + return this.processStream(conn, handlers); + } + + // 解析命令:"/command args" -> { name, args } + private parseCommand(content: string): { name: string; args: string } | null { + const trimmed = content.trim(); + if (!trimmed.startsWith("/")) return null; + const spaceIdx = trimmed.indexOf(" "); + if (spaceIdx === -1) return { name: trimmed, args: "" }; + return { name: trimmed.slice(0, spaceIdx), args: trimmed.slice(spaceIdx + 1).trim() }; + } + + // 尝试执行命令,未注册的命令返回 undefined(正常发送给 LLM) + private async tryExecuteCommand(content: string): Promise { + const parsed = this.parseCommand(content); + if (!parsed) return undefined; + + const handler = this.commandHandlers.get(parsed.name); + if (!handler) return undefined; + + const result = await handler(parsed.args, this); + // 命令结果始终为纯文本 string + return { content: (result || "") as string, command: true }; + } + + // 合并实例级别和调用级别的工具定义 + protected mergeTools(callTools?: ChatOptions["tools"]) { + const toolDefs: ToolDefinition[] = [...this.toolDefs]; + const handlers = new Map(this.toolHandlers); + + if (callTools) { + for (const tool of callTools) { + // 调用级别的工具覆盖实例级别的同名工具 + if (!handlers.has(tool.name)) { + toolDefs.push({ name: tool.name, description: tool.description, parameters: tool.parameters }); + } + handlers.set(tool.name, tool.handler); + } + } + + return { toolDefs, handlers }; + } + + // 获取对话历史 + async getMessages(): Promise { + if (this.ephemeral) { + // ephemeral 模式:从内存历史转换为 ChatMessage 格式 + return this.messageHistory.map((msg, idx) => ({ + id: `ephemeral-${idx}`, + conversationId: this.conv.id, + role: msg.role, + content: msg.content, + toolCallId: msg.toolCallId, + toolCalls: msg.toolCalls, + createtime: Date.now(), + })); + } + const messages = await this.gmSendMessage("CAT_agentConversation", [ + { + action: "getMessages", + conversationId: this.conv.id, + scriptUuid: this.scriptUuid, + } as ConversationApiRequest, + ]); + return messages || []; + } + + // 清空对话消息历史 + async clear(): Promise { + if (this.ephemeral) { + this.messageHistory = []; + return; + } + await this.gmSendMessage("CAT_agentConversation", [ + { + action: "clearMessages", + conversationId: this.conv.id, + scriptUuid: this.scriptUuid, + } as ConversationApiRequest, + ]); + } + + // 持久化对话 + async save(): Promise { + await this.gmSendMessage("CAT_agentConversation", [ + { + action: "save", + conversationId: this.conv.id, + scriptUuid: this.scriptUuid, + } as ConversationApiRequest, + ]); + } + + // 附加到后台运行中的会话,返回流式事件 + async attach(): Promise> { + const conn = await this.gmConnect("CAT_agentAttachToConversation", [ + { conversationId: this.conv.id, scriptUuid: this.scriptUuid }, + ]); + return this.processStream(conn, new Map()); + } + + // 处理非流式 chat 的响应 + protected processChat( + conn: MessageConnect, + handlers: Map) => Promise> + ): Promise { + return new Promise((resolve, reject) => { + let content = ""; + let thinking = ""; + let toolCalls: ToolCall[] = []; + const contentBlocks: ContentBlock[] = []; + let currentToolCall: ToolCall | null = null; + let usage: { inputTokens: number; outputTokens: number } | undefined; + let durationMs: number | undefined; + + // 一轮(assistant + 其 tool calls 的执行结果)结束时,追加到 ephemeral 内存历史, + // 避免把多轮 tool calls 及其结果压平成一条消息,或遗漏对应的 tool 结果消息。 + const finishRound = () => { + if (currentToolCall) { + toolCalls.push(currentToolCall); + currentToolCall = null; + } + if (this.ephemeral && (content || toolCalls.length > 0)) { + this.messageHistory.push({ + role: "assistant", + content, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + }); + for (const tc of toolCalls) { + if (tc.result !== undefined) { + this.messageHistory.push({ role: "tool", content: tc.result, toolCallId: tc.id }); + } + } + } + content = ""; + toolCalls = []; + }; + + conn.onMessage(async (msg: any) => { + if (msg.action === "executeTools") { + // Service Worker 请求执行 tools + const requestedToolCalls: ToolCall[] = msg.data; + const results = await this.executeTools(requestedToolCalls, handlers); + conn.sendMessage({ action: "toolResults", data: results }); + return; + } + + if (msg.action !== "event") return; + const event: ChatStreamEvent = msg.data; + + switch (event.type) { + case "content_delta": + content += event.delta; + break; + case "thinking_delta": + thinking += event.delta; + break; + case "content_block_complete": + // 收集模型生成的图片/文件/音频 blocks(data 已由 finalize 保存到 attachment 存储) + contentBlocks.push(event.block); + break; + case "tool_call_start": + if (currentToolCall) toolCalls.push(currentToolCall); + currentToolCall = { ...event.toolCall, arguments: event.toolCall.arguments || "" }; + break; + case "tool_call_delta": + if (currentToolCall) currentToolCall.arguments += event.delta; + break; + case "tool_call_complete": { + const target = + currentToolCall?.id === event.id ? currentToolCall : toolCalls.find((tc) => tc.id === event.id); + if (target) { + target.result = event.result; + target.status = event.status ?? "completed"; + target.attachments = event.attachments; + } + break; + } + case "new_message": + finishRound(); + break; + case "done": { + if (currentToolCall) { + toolCalls.push(currentToolCall); + currentToolCall = null; + } + if (event.usage) usage = event.usage; + durationMs = event.durationMs; + // 合并文本和 content blocks 到 MessageContent + let finalContent: MessageContent = content; + if (contentBlocks.length > 0) { + const blocks: ContentBlock[] = []; + if (content) blocks.push({ type: "text", text: content }); + blocks.push(...contentBlocks); + finalContent = blocks; + } + resolve({ + content: finalContent, + thinking: thinking || undefined, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + usage, + durationMs, + }); + break; + } + case "error": + reject( + Object.assign(new Error(event.message), { + errorCode: event.errorCode, + usage: event.usage, + durationMs: event.durationMs, + }) + ); + break; + } + }); + + conn.onDisconnect(() => { + reject(new Error("Connection disconnected")); + }); + }); + } + + // 处理流式 chat 的响应 + protected processStream( + conn: MessageConnect, + handlers: Map) => Promise> + ): AsyncIterable { + const chunks: StreamChunk[] = []; + let resolve: (() => void) | null = null; + let done = false; + let error: Error | null = null; + // 按 id 跟踪进行中的 tool call,供 tool_call_delta / tool_call_complete 原地更新 + const toolCallsById = new Map(); + + conn.onMessage(async (msg: any) => { + if (msg.action === "executeTools") { + const requestedToolCalls: ToolCall[] = msg.data; + const results = await this.executeTools(requestedToolCalls, handlers); + conn.sendMessage({ action: "toolResults", data: results }); + return; + } + + if (msg.action !== "event") return; + const event: ChatStreamEvent = msg.data; + + let chunk: StreamChunk | null = null; + switch (event.type) { + case "content_delta": + chunk = { type: "content_delta", content: event.delta }; + break; + case "thinking_delta": + chunk = { type: "thinking_delta", content: event.delta }; + break; + case "content_block_complete": + chunk = { type: "content_block", block: event.block }; + break; + case "tool_call_start": { + const tc: ToolCall = { ...event.toolCall, arguments: event.toolCall.arguments || "" }; + toolCallsById.set(tc.id, tc); + chunk = { type: "tool_call", toolCall: { ...tc } }; + break; + } + case "tool_call_delta": { + const tc = toolCallsById.get(event.id); + if (tc) { + tc.arguments += event.delta; + chunk = { type: "tool_call", toolCall: { ...tc } }; + } + break; + } + case "tool_call_complete": { + const tc = toolCallsById.get(event.id); + if (tc) { + tc.result = event.result; + tc.status = event.status ?? "completed"; + tc.attachments = event.attachments; + } + chunk = { + type: "tool_call_complete", + toolCall: tc + ? { ...tc } + : { + id: event.id, + name: "", + arguments: "", + result: event.result, + status: event.status ?? "completed", + attachments: event.attachments, + }, + }; + break; + } + case "new_message": + chunk = { type: "new_message" }; + break; + case "done": + chunk = { type: "done", usage: event.usage, durationMs: event.durationMs }; + done = true; + break; + case "error": + chunk = { + type: "error", + error: event.message, + errorCode: event.errorCode, + usage: event.usage, + durationMs: event.durationMs, + }; + error = Object.assign(new Error(event.message), { + errorCode: event.errorCode, + usage: event.usage, + durationMs: event.durationMs, + }); + done = true; + break; + } + + if (chunk) { + chunks.push(chunk); + resolve?.(); + } + }); + + conn.onDisconnect(() => { + done = true; + error = error || new Error("Connection disconnected"); + resolve?.(); + }); + + return { + [Symbol.asyncIterator]() { + return { + async next(): Promise> { + while (chunks.length === 0 && !done) { + await new Promise((r) => { + resolve = r; + }); + } + + if (chunks.length > 0) { + return { value: chunks.shift()!, done: false }; + } + + if (error && !done) throw error; + return { value: undefined as any, done: true }; + }, + }; + }, + }; + } + + // 处理 ephemeral 流式 chat 的响应(收集 assistant 消息到内存历史) + protected processStreamEphemeral( + conn: MessageConnect, + handlers: Map) => Promise> + ): AsyncIterable { + const inner = this.processStream(conn, handlers); + const messageHistory = this.messageHistory; + let content = ""; + let toolCalls: ToolCall[] = []; + + // 一轮结束(new_message 或流结束)时追加 assistant + 对应 tool 结果消息, + // 避免把多轮 tool calls 压平成一条消息、或遗漏 tool 结果消息。 + const finishRound = () => { + if (content || toolCalls.length > 0) { + messageHistory.push({ + role: "assistant", + content, + toolCalls: toolCalls.length > 0 ? [...toolCalls] : undefined, + }); + for (const tc of toolCalls) { + if (tc.result !== undefined) { + messageHistory.push({ role: "tool", content: tc.result, toolCallId: tc.id }); + } + } + } + content = ""; + toolCalls = []; + }; + + const upsertToolCall = (tc: ToolCall) => { + const idx = toolCalls.findIndex((existing) => existing.id === tc.id); + if (idx >= 0) toolCalls[idx] = tc; + else toolCalls.push(tc); + }; + + return { + [Symbol.asyncIterator]() { + const iter = inner[Symbol.asyncIterator](); + return { + async next(): Promise> { + const result = await iter.next(); + if (result.done) { + finishRound(); + return result; + } + + const chunk = result.value; + switch (chunk.type) { + case "content_delta": + content += chunk.content || ""; + break; + case "tool_call": + case "tool_call_complete": + if (chunk.toolCall) upsertToolCall(chunk.toolCall); + break; + case "new_message": + finishRound(); + break; + } + return result; + }, + }; + }, + }; + } + + // 执行用户定义的 tool handlers + protected async executeTools( + toolCalls: ToolCall[], + handlers: Map) => Promise> + ): Promise> { + const results: Array<{ id: string; result: string; error?: boolean }> = []; + + for (const tc of toolCalls) { + const handler = handlers.get(tc.name); + if (!handler) { + results.push({ id: tc.id, result: JSON.stringify({ error: `Tool "${tc.name}" not found` }), error: true }); + continue; + } + + try { + let args: Record = {}; + if (tc.arguments) { + args = JSON.parse(tc.arguments); + } + const result = await handler(args); + results.push({ id: tc.id, result: typeof result === "string" ? result : JSON.stringify(result) }); + } catch (e: any) { + const errorMsg = + e instanceof Error + ? e.message || e.toString() + : typeof e === "string" + ? e + : String(e) || "Tool execution failed"; + results.push({ id: tc.id, result: JSON.stringify({ error: errorMsg }), error: true }); + } + } + + return results; + } +} + +// 运行时 this 是 GM_Base 实例,定义其实际拥有的字段类型 +interface GMBaseContext { + sendMessage: (api: string, params: unknown[]) => Promise; + connect: (api: string, params: unknown[]) => Promise; + scriptRes?: { uuid: string }; +} + +// 构建 ConversationInstance,独立函数避免 this 绑定问题 +// (装饰器方法运行时 this 是 GM_Base 实例,不是 CATAgentApi) +function buildInstance( + ctx: GMBaseContext, + conv: Conversation, + options?: ConversationCreateOptions +): ConversationInstance { + return new ConversationInstance( + conv, + ctx.sendMessage.bind(ctx), + ctx.connect.bind(ctx), + ctx.scriptRes?.uuid || "", + options?.maxIterations || 20, + options?.tools, + options?.commands, + options?.ephemeral, + options?.system, + options?.cache, + options?.background + ); +} + +// CAT.agent.conversation API 对象,注入到脚本上下文 +// 使用 @GMContext.API 装饰器注册到 "CAT.agent.conversation" grant +export default class CATAgentApi { + // 标记为 protected 的内部状态(由 GM_Base 绑定) + @GMContext.protected() + protected sendMessage!: (api: string, params: any[]) => Promise; + + @GMContext.protected() + protected connect!: (api: string, params: any[]) => Promise; + + @GMContext.protected() + protected scriptRes?: any; + + // CAT.agent.conversation.create() + @GMContext.API({ follow: "CAT.agent.conversation" }) + public "CAT.agent.conversation.create"(options: ConversationCreateOptions = {}): Promise { + return (async () => { + if (options.ephemeral) { + // ephemeral 模式:不发请求到 SW,直接在脚本端构造 + const conv: Conversation = { + id: options.id || uuidv4(), + title: "New Chat", + modelId: options.model || "", + system: options.system, + createtime: Date.now(), + updatetime: Date.now(), + }; + return buildInstance(this as unknown as GMBaseContext, conv, options); + } + + const { tools: _tools, ephemeral: _ephemeral, ...serverOptions } = options; + const conv = (await this.sendMessage("CAT_agentConversation", [ + { action: "create", options: serverOptions, scriptUuid: this.scriptRes?.uuid || "" } as ConversationApiRequest, + ])) as Conversation; + return buildInstance(this as unknown as GMBaseContext, conv, options); + })(); + } + + // CAT.agent.conversation.get() + @GMContext.API({ follow: "CAT.agent.conversation" }) + public "CAT.agent.conversation.get"(id: string): Promise { + return (async () => { + const conv = (await this.sendMessage("CAT_agentConversation", [ + { action: "get", id, scriptUuid: this.scriptRes?.uuid || "" } as ConversationApiRequest, + ])) as Conversation | null; + if (!conv) return null; + return buildInstance(this as unknown as GMBaseContext, conv); + })(); + } +} diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index 8e874ce95..9d152c6ca 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -15,6 +15,7 @@ import { type VSCodeConnectParam } from "../offscreen/vscode-connect"; import { type ScriptInfo } from "@App/pkg/utils/scriptInstall"; import type { AgentModelConfig, MCPApiRequest, SkillConfigField } from "@App/app/service/agent/core/types"; import type { SearchEngineConfig } from "@App/app/service/agent/core/tools/search_config"; +import type { AgentGeneralConfig } from "@App/app/service/agent/core/agent_config"; import type { ScriptService, TCheckScriptUpdateOption, @@ -477,6 +478,15 @@ export class AgentClient extends Client { return this.do("saveSearchConfig", config); } + // Agent 通用设置 + getAgentConfig(): Promise { + return this.doThrow("getAgentConfig"); + } + + saveAgentConfig(config: AgentGeneralConfig) { + return this.do("saveAgentConfig", config); + } + // MCP API mcpApi(request: MCPApiRequest): Promise { return this.doThrow("mcpApi", request); diff --git a/src/locales/de-DE/agent.json b/src/locales/de-DE/agent.json index 40d0543ee..183cc6ee5 100644 --- a/src/locales/de-DE/agent.json +++ b/src/locales/de-DE/agent.json @@ -86,6 +86,12 @@ "chat_copy_success": "Kopiert", "chat_regenerate": "Neu generieren", "chat_streaming": "Wird generiert...", + "chat_continue_max_iterations": "Unterhaltung fortsetzen", + "chat_continue_message": "Bitte fahre fort.", + "chat_guard_question": "Die Loop-Guard-Warnung des Agents wurde {{count}} Mal ausgelöst. Fortfahren oder stoppen?", + "chat_guard_continue": "Fortfahren", + "chat_guard_stop": "Stoppen", + "chat_guard_stopped_message": "Auf Wunsch des Benutzers nach wiederholten Loop-Guard-Warnungen angehalten.", "chat_loading_audio": "Audio wird geladen...", "chat_starting": "Wird gestartet...", "chat_retrying": "Wiederholung läuft ({{attempt}}/{{max}})...", @@ -170,10 +176,13 @@ "settings_title": "Agent-Einstellungen", "settings_subtitle": "Modell-, Such- und allgemeine Einstellungen · Änderungen wirken sofort", "settings_cat_model": "Modell", + "settings_cat_conversation": "Unterhaltung", "settings_cat_search": "Suche", "summary_model": "Zusammenfassungsmodell", "summary_model_desc": "Für Web-Zusammenfassungen. Wenn nicht eingestellt, wird das Standardmodell verwendet", "summary_model_placeholder": "Standardmodell verwenden", + "chat_max_iterations": "Max. Tool-Aufrufe", + "chat_max_iterations_desc": "Maximale Anzahl aufeinanderfolgender Tool-Aufrufe des Agent in einer Unterhaltung. Eine Erhöhung kann die Token-Kosten einer einzelnen Aufgabe erhöhen — behalte dein Guthaben im Blick.", "search_engine": "Suchmaschine", "search_engine_desc": "Die vom web_search-Tool verwendete Suchquelle.", "search_engine_baidu": "Baidu", diff --git a/src/locales/en-US/agent.json b/src/locales/en-US/agent.json index d4615aab2..3e0d3700e 100644 --- a/src/locales/en-US/agent.json +++ b/src/locales/en-US/agent.json @@ -86,6 +86,12 @@ "chat_copy_success": "Copied", "chat_regenerate": "Regenerate", "chat_streaming": "Generating...", + "chat_continue_max_iterations": "Continue Conversation", + "chat_continue_message": "Please continue.", + "chat_guard_question": "The Agent has triggered the loop-guard warning {{count}} times. Continue or stop?", + "chat_guard_continue": "Continue", + "chat_guard_stop": "Stop", + "chat_guard_stopped_message": "Stopped at the user's request after repeated loop-guard warnings.", "chat_loading_audio": "Loading audio...", "chat_starting": "Starting...", "chat_retrying": "Retrying ({{attempt}}/{{max}})...", @@ -203,10 +209,13 @@ "settings_title": "Agent Settings", "settings_subtitle": "Model, search and general preferences · changes apply instantly", "settings_cat_model": "Model", + "settings_cat_conversation": "Conversation", "settings_cat_search": "Search", "summary_model": "Summary Model", "summary_model_desc": "Model for summarization, falls back to default if not set", "summary_model_placeholder": "Use default model", + "chat_max_iterations": "Max Tool Call Iterations", + "chat_max_iterations_desc": "Maximum number of consecutive tool calls the Agent can make in one conversation. Increasing this may raise the token cost of a single task — keep an eye on your balance.", "search_engine": "Search Engine", "search_engine_desc": "The retrieval source used by the web_search tool.", "search_engine_baidu": "Baidu", diff --git a/src/locales/ja-JP/agent.json b/src/locales/ja-JP/agent.json index c8b5c0ba8..346a3a746 100644 --- a/src/locales/ja-JP/agent.json +++ b/src/locales/ja-JP/agent.json @@ -86,6 +86,12 @@ "chat_copy_success": "コピーしました", "chat_regenerate": "再生成", "chat_streaming": "生成中...", + "chat_continue_max_iterations": "会話を続ける", + "chat_continue_message": "続けてください。", + "chat_guard_question": "Agent のループガード警告が {{count}} 回発生しました。続行しますか?", + "chat_guard_continue": "続行", + "chat_guard_stop": "停止", + "chat_guard_stopped_message": "ループガード警告が繰り返されたため、ユーザーの要求で停止しました。", "chat_loading_audio": "音声を読み込み中...", "chat_starting": "起動中...", "chat_retrying": "再試行中 ({{attempt}}/{{max}})...", @@ -170,10 +176,13 @@ "settings_title": "Agent 設定", "settings_subtitle": "モデル、検索、一般設定 · 変更は即時反映されます", "settings_cat_model": "モデル", + "settings_cat_conversation": "会話", "settings_cat_search": "検索", "summary_model": "要約モデル", "summary_model_desc": "ウェブ要約などに使用。未設定の場合はデフォルトモデルを使用", "summary_model_placeholder": "デフォルトモデルを使用", + "chat_max_iterations": "最大ツール呼び出し回数", + "chat_max_iterations_desc": "1回の会話でAgentが連続してツールを呼び出せる最大回数です。この値を大きくすると、1回のタスクのトークン消費が増える可能性があります。残高にご注意ください。", "search_engine": "検索エンジン", "search_engine_desc": "web_search ツールが使用する検索ソースです。", "search_engine_baidu": "百度", diff --git a/src/locales/ru-RU/agent.json b/src/locales/ru-RU/agent.json index c2b7c7fa2..24039a1b0 100644 --- a/src/locales/ru-RU/agent.json +++ b/src/locales/ru-RU/agent.json @@ -86,6 +86,12 @@ "chat_copy_success": "Скопировано", "chat_regenerate": "Сгенерировать заново", "chat_streaming": "Генерация...", + "chat_continue_max_iterations": "Продолжить беседу", + "chat_continue_message": "Пожалуйста, продолжайте.", + "chat_guard_question": "Предупреждение защиты от циклов Agent сработало {{count}} раз. Продолжить или остановиться?", + "chat_guard_continue": "Продолжить", + "chat_guard_stop": "Остановить", + "chat_guard_stopped_message": "Остановлено по запросу пользователя после повторных предупреждений о цикле.", "chat_loading_audio": "Загрузка аудио...", "chat_starting": "Запуск...", "chat_retrying": "Повтор ({{attempt}}/{{max}})...", @@ -170,10 +176,13 @@ "settings_title": "Настройки Agent", "settings_subtitle": "Модель, поиск и общие настройки · изменения применяются сразу", "settings_cat_model": "Модель", + "settings_cat_conversation": "Беседа", "settings_cat_search": "Поиск", "summary_model": "Модель для резюме", "summary_model_desc": "Используется для суммаризации веб-страниц. При отсутствии используется модель по умолчанию", "summary_model_placeholder": "Использовать модель по умолчанию", + "chat_max_iterations": "Макс. число вызовов инструментов", + "chat_max_iterations_desc": "Максимальное количество последовательных вызовов инструментов Agent в рамках одной беседы. Увеличение этого значения может повысить расход токенов за одну задачу — следите за балансом.", "search_engine": "Поисковая система", "search_engine_desc": "Источник поиска, используемый инструментом web_search.", "search_engine_baidu": "Baidu", diff --git a/src/locales/tr-TR/agent.json b/src/locales/tr-TR/agent.json index 3b1d4d14f..b0398d344 100644 --- a/src/locales/tr-TR/agent.json +++ b/src/locales/tr-TR/agent.json @@ -86,6 +86,12 @@ "chat_copy_success": "Kopyalandı", "chat_regenerate": "Yeniden oluştur", "chat_streaming": "Oluşturuluyor...", + "chat_continue_max_iterations": "Sohbete Devam Et", + "chat_continue_message": "Lütfen devam edin.", + "chat_guard_question": "Agent döngü koruması uyarısı {{count}} kez tetiklendi. Devam edilsin mi?", + "chat_guard_continue": "Devam et", + "chat_guard_stop": "Durdur", + "chat_guard_stopped_message": "Tekrarlanan döngü koruması uyarılarından sonra kullanıcının isteğiyle durduruldu.", "chat_loading_audio": "Ses yükleniyor...", "chat_starting": "Başlatılıyor...", "chat_retrying": "Yeniden deneniyor ({{attempt}}/{{max}})...", @@ -203,10 +209,13 @@ "settings_title": "Ajan Ayarları", "settings_subtitle": "Model, arama ve genel tercihler · değişiklikler anında uygulanır", "settings_cat_model": "Model", + "settings_cat_conversation": "Sohbet", "settings_cat_search": "Arama", "summary_model": "Özet Modeli", "summary_model_desc": "Özetleme için kullanılacak model. Ayarlanmazsa varsayılan model kullanılır.", "summary_model_placeholder": "Varsayılan modeli kullan", + "chat_max_iterations": "Maks. Araç Çağrısı Sayısı", + "chat_max_iterations_desc": "Agent'ın tek bir sohbette art arda yapabileceği maksimum araç çağrısı sayısı. Bu değeri artırmak, tek bir görevin token maliyetini yükseltebilir — bakiyenizi kontrol edin.", "search_engine": "Arama Motoru", "search_engine_desc": "web_search aracı tarafından kullanılan arama motoru.", "search_engine_baidu": "Baidu", diff --git a/src/locales/vi-VN/agent.json b/src/locales/vi-VN/agent.json index 74115f117..b76503385 100644 --- a/src/locales/vi-VN/agent.json +++ b/src/locales/vi-VN/agent.json @@ -86,6 +86,12 @@ "chat_copy_success": "Đã sao chép", "chat_regenerate": "Tạo lại", "chat_streaming": "Đang tạo...", + "chat_continue_max_iterations": "Tiếp tục cuộc trò chuyện", + "chat_continue_message": "Vui lòng tiếp tục.", + "chat_guard_question": "Agent đã kích hoạt cảnh báo vòng lặp {{count}} lần. Tiếp tục hay dừng?", + "chat_guard_continue": "Tiếp tục", + "chat_guard_stop": "Dừng", + "chat_guard_stopped_message": "Đã dừng theo yêu cầu của người dùng sau nhiều cảnh báo vòng lặp.", "chat_loading_audio": "Đang tải âm thanh...", "chat_starting": "Đang khởi động...", "chat_retrying": "Đang thử lại ({{attempt}}/{{max}})...", @@ -170,10 +176,13 @@ "settings_title": "Cài đặt Agent", "settings_subtitle": "Mô hình, tìm kiếm và tùy chọn chung · thay đổi áp dụng tức thì", "settings_cat_model": "Mô hình", + "settings_cat_conversation": "Cuộc trò chuyện", "settings_cat_search": "Tìm kiếm", "summary_model": "Mô hình tóm tắt", "summary_model_desc": "Dùng cho tóm tắt trang web. Nếu chưa đặt sẽ dùng mô hình mặc định", "summary_model_placeholder": "Dùng mô hình mặc định", + "chat_max_iterations": "Số lần gọi công cụ tối đa", + "chat_max_iterations_desc": "Số lần gọi công cụ liên tiếp tối đa mà Agent có thể thực hiện trong một cuộc trò chuyện. Tăng giá trị này có thể làm tăng chi phí token của một tác vụ — hãy chú ý số dư của bạn.", "search_engine": "Công cụ tìm kiếm", "search_engine_desc": "Nguồn truy xuất được công cụ web_search sử dụng.", "search_engine_baidu": "Baidu", diff --git a/src/locales/zh-CN/agent.json b/src/locales/zh-CN/agent.json index 8743c1612..154ced59e 100644 --- a/src/locales/zh-CN/agent.json +++ b/src/locales/zh-CN/agent.json @@ -86,6 +86,12 @@ "chat_copy_success": "已复制", "chat_regenerate": "重新生成", "chat_streaming": "生成中...", + "chat_continue_max_iterations": "继续对话", + "chat_continue_message": "请继续。", + "chat_guard_question": "Agent 已触发循环检测警告 {{count}} 次。是否继续?", + "chat_guard_continue": "继续", + "chat_guard_stop": "停止", + "chat_guard_stopped_message": "用户在多次循环检测警告后请求停止。", "chat_loading_audio": "正在加载音频...", "chat_starting": "正在启动...", "chat_retrying": "正在重试 ({{attempt}}/{{max}})...", @@ -203,10 +209,13 @@ "settings_title": "Agent 设置", "settings_subtitle": "模型、搜索与通用偏好 · 修改即时生效", "settings_cat_model": "模型", + "settings_cat_conversation": "对话", "settings_cat_search": "搜索", "summary_model": "摘要模型", "summary_model_desc": "用于网页摘要等场景,未设置时使用默认模型", "summary_model_placeholder": "使用默认模型", + "chat_max_iterations": "最大工具调用次数", + "chat_max_iterations_desc": "单次对话中 Agent 可连续调用工具的最大次数。调大此选项可能会增加单次任务的 Token 开销,请留意余额。", "search_engine": "搜索引擎", "search_engine_desc": "web_search 工具使用的检索来源。", "search_engine_baidu": "百度", diff --git a/src/locales/zh-TW/agent.json b/src/locales/zh-TW/agent.json index 678c7d573..408bd5632 100644 --- a/src/locales/zh-TW/agent.json +++ b/src/locales/zh-TW/agent.json @@ -86,6 +86,12 @@ "chat_copy_success": "已複製", "chat_regenerate": "重新生成", "chat_streaming": "生成中...", + "chat_continue_max_iterations": "繼續對話", + "chat_continue_message": "請繼續。", + "chat_guard_question": "Agent 已觸發循環偵測警告 {{count}} 次。是否繼續?", + "chat_guard_continue": "繼續", + "chat_guard_stop": "停止", + "chat_guard_stopped_message": "使用者在多次循環偵測警告後要求停止。", "chat_loading_audio": "正在載入音訊...", "chat_starting": "正在啟動...", "chat_retrying": "正在重試 ({{attempt}}/{{max}})...", @@ -170,10 +176,13 @@ "settings_title": "Agent 設定", "settings_subtitle": "模型、搜尋與一般偏好 · 修改即時生效", "settings_cat_model": "模型", + "settings_cat_conversation": "對話", "settings_cat_search": "搜尋", "summary_model": "摘要模型", "summary_model_desc": "用於網頁摘要等場景,未設定時使用預設模型", "summary_model_placeholder": "使用預設模型", + "chat_max_iterations": "最大工具呼叫次數", + "chat_max_iterations_desc": "單一對話中 Agent 可連續呼叫工具的最大次數。調大此選項可能會增加單次任務的 Token 開銷,請留意餘額。", "search_engine": "搜尋引擎", "search_engine_desc": "web_search 工具使用的檢索來源。", "search_engine_baidu": "百度", diff --git a/src/pages/options/routes/Agent/Chat/AskUserBlock.test.tsx b/src/pages/options/routes/Agent/Chat/AskUserBlock.test.tsx index 17fd3e9d8..e5bbb6b49 100644 --- a/src/pages/options/routes/Agent/Chat/AskUserBlock.test.tsx +++ b/src/pages/options/routes/Agent/Chat/AskUserBlock.test.tsx @@ -43,4 +43,22 @@ describe("用户提问块 AskUserBlock", () => { expect(screen.queryByTestId("ask-input")).toBeNull(); expect(screen.getByText("红")).toBeInTheDocument(); }); + + it("禁用自定义输入时使用稳定选项值", () => { + const onRespond = vi.fn(); + render( + + ); + + expect(screen.queryByTestId("ask-input")).toBeNull(); + fireEvent.click(screen.getByTestId("ask-option-stop")); + expect(onRespond).toHaveBeenCalledWith("guard-1", "stop"); + }); }); diff --git a/src/pages/options/routes/Agent/Chat/AskUserBlock.tsx b/src/pages/options/routes/Agent/Chat/AskUserBlock.tsx index a345a5554..e2885aa22 100644 --- a/src/pages/options/routes/Agent/Chat/AskUserBlock.tsx +++ b/src/pages/options/routes/Agent/Chat/AskUserBlock.tsx @@ -7,13 +7,17 @@ export default function AskUserBlock({ id, question, options, + optionValues, multiple, + allowCustom = true, onRespond, }: { id: string; question: string; options?: string[]; + optionValues?: string[]; multiple?: boolean; + allowCustom?: boolean; onRespond: (id: string, answer: string) => void; }) { const { t } = useTranslation(); @@ -70,6 +74,7 @@ export default function AskUserBlock({ })(); const hasOptions = options && options.length > 0; + const getOptionValue = (index: number, label: string) => optionValues?.[index] ?? label; // 已提交:紧凑的完成状态 if (submitted) { @@ -104,14 +109,15 @@ export default function AskUserBlock({ {/* 选项 */} {hasOptions && (
- {options.map((opt) => { - const isSelected = selectedOptions.includes(opt); + {options.map((opt, index) => { + const value = getOptionValue(index, opt); + const isSelected = selectedOptions.includes(value); return ( -
+ {allowCustom && ( +
+ setAnswer(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSubmit()} + placeholder={t("agent:chat_input_placeholder")} + className="flex-1 bg-transparent border-none outline-none text-sm text-foreground placeholder:text-muted-foreground min-w-0" + /> + +
+ )} diff --git a/src/pages/options/routes/Agent/Chat/ChatArea.tsx b/src/pages/options/routes/Agent/Chat/ChatArea.tsx index 67f63acbd..93cc25b15 100644 --- a/src/pages/options/routes/Agent/Chat/ChatArea.tsx +++ b/src/pages/options/routes/Agent/Chat/ChatArea.tsx @@ -25,6 +25,7 @@ import { computeEditAction, computeUserRegenerateAction, findNextAssistantGroupIndex, + canContinueMaxIterationsGroup, type MessageGroup, } from "./chat_utils"; @@ -205,7 +206,7 @@ export default function ChatArea({ case "tool_call_complete": { const tc = sa.currentToolCalls.find((x) => x.id === event.id); if (tc) { - tc.status = "completed"; + tc.status = event.status || "completed"; tc.result = event.result; tc.attachments = event.attachments; } @@ -227,6 +228,7 @@ export default function ChatArea({ sa.retryInfo = { attempt: event.attempt, maxRetries: event.maxRetries, error: event.error }; break; case "done": + case "error": if (event.usage) { if (!sa.usage) sa.usage = { inputTokens: 0, outputTokens: 0 }; sa.usage.inputTokens += event.usage.inputTokens; @@ -236,8 +238,6 @@ export default function ChatArea({ sa.usage.cacheReadInputTokens = (sa.usage.cacheReadInputTokens || 0) + (event.usage.cacheReadInputTokens || 0); } - // falls through - case "error": sa.retryInfo = undefined; if (sa.currentContent || sa.currentThinking || sa.currentToolCalls.length > 0) { sa.completedMessages.push({ @@ -306,7 +306,7 @@ export default function ChatArea({ case "tool_call_complete": { const tc = msg.toolCalls?.find((x) => x.id === event.id); if (tc) { - tc.status = "completed"; + tc.status = event.status || "completed"; tc.result = event.result; tc.attachments = event.attachments; } @@ -379,6 +379,9 @@ export default function ChatArea({ break; case "error": msg.error = event.message; + msg.errorCode = event.errorCode; + if (event.usage) msg.usage = event.usage; + if (event.durationMs != null) msg.durationMs = event.durationMs; break; case "done": if (event.usage) msg.usage = event.usage; @@ -652,6 +655,10 @@ export default function ChatArea({ const handleStop = useCallback(async () => { clearRetryTimer(); stopGeneration(); + // 只做纯 UI 侧的乐观更新(清掉"正在流式"标记、把仍显示 running 的 toolCall 标灰); + // 不在这里处理排队消息或重新加载历史——那必须等真正的终态事件到达、取消记录落库完成后, + // 由 onDone(createDoneCallback,见 stopGeneration 里保留连接直到终态事件到达)统一处理, + // 否则排队消息可能在旧会话仍占用中时被拒绝、且从未持久化就丢失(见 finding 6) streamingMsgRef.current = null; setStreamingMsgId(null); setMessages((prev) => { @@ -665,13 +672,7 @@ export default function ChatArea({ }; }); }); - if (pendingMessageRef.current) { - void processPendingMessage(); - } else { - void loadMessages(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [clearRetryTimer, stopGeneration, setMessages, loadMessages]); + }, [clearRetryTimer, stopGeneration, setMessages]); const handleCancelPending = useCallback(() => { const pending = pendingMessageRef.current; @@ -731,6 +732,11 @@ export default function ChatArea({ onCopy={() => handleCopy(group.messages)} onRegenerate={() => handleRegenerate(messageGroups, groupIndex)} onDelete={() => handleDeleteRound(messageGroups, groupIndex)} + onContinue={ + !isStreaming && canContinueMaxIterationsGroup(group, groupIndex === messageGroups.length - 1) + ? () => void handleSend(t("agent:chat_continue_message")) + : undefined + } /> ) ) @@ -740,7 +746,9 @@ export default function ChatArea({ id={askUserPending.id} question={askUserPending.question} options={askUserPending.options} + optionValues={askUserPending.optionValues} multiple={askUserPending.multiple} + allowCustom={askUserPending.allowCustom} onRespond={respondToAskUser} /> )} diff --git a/src/pages/options/routes/Agent/Chat/MessageItem.test.tsx b/src/pages/options/routes/Agent/Chat/MessageItem.test.tsx index 7e26f4c3f..d9cc09ab1 100644 --- a/src/pages/options/routes/Agent/Chat/MessageItem.test.tsx +++ b/src/pages/options/routes/Agent/Chat/MessageItem.test.tsx @@ -97,4 +97,35 @@ describe("助手消息组 AssistantMessageGroup", () => { render(); expect(screen.getByTestId("subagent-trigger")).toBeInTheDocument(); }); + + it("达到最大工具调用次数时展示继续按钮,点击触发 onContinue", () => { + const onContinue = vi.fn(); + const m = msg({ + role: "assistant", + content: "", + error: "Tool calling loop exceeded maximum iterations (50)", + errorCode: "max_iterations", + }); + render(); + fireEvent.click(screen.getByTestId("continue-max-iterations")); + expect(onContinue).toHaveBeenCalledOnce(); + }); + + it("普通错误(非 max_iterations)不展示继续按钮", () => { + const onContinue = vi.fn(); + const m = msg({ role: "assistant", content: "", error: "网络请求失败", errorCode: "api_error" }); + render(); + expect(screen.queryByTestId("continue-max-iterations")).toBeNull(); + }); + + it("未传入 onContinue 时即使达到最大迭代次数也不展示继续按钮", () => { + const m = msg({ + role: "assistant", + content: "", + error: "Tool calling loop exceeded maximum iterations (50)", + errorCode: "max_iterations", + }); + render(); + expect(screen.queryByTestId("continue-max-iterations")).toBeNull(); + }); }); diff --git a/src/pages/options/routes/Agent/Chat/MessageItem.tsx b/src/pages/options/routes/Agent/Chat/MessageItem.tsx index c0544c9aa..927d6bb7b 100644 --- a/src/pages/options/routes/Agent/Chat/MessageItem.tsx +++ b/src/pages/options/routes/Agent/Chat/MessageItem.tsx @@ -30,10 +30,12 @@ function AssistantMessageContent({ message, isStreaming, subAgents, + onContinue, }: { message: ChatMessage; isStreaming?: boolean; subAgents?: Map; + onContinue?: () => void; }) { const { t } = useTranslation(); return ( @@ -73,7 +75,19 @@ function AssistantMessageContent({ {message.error && (
- {message.error} +
+ {message.error} + {message.errorCode === "max_iterations" && onContinue && ( + + )} +
)} @@ -466,6 +480,7 @@ export function AssistantMessageGroup({ onCopy, onRegenerate, onDelete, + onContinue, }: { messages: ChatMessage[]; streamingId?: string; @@ -475,6 +490,7 @@ export function AssistantMessageGroup({ onCopy: () => void; onRegenerate: () => void; onDelete: () => void; + onContinue?: () => void; }) { const lastMsg = messages[messages.length - 1]; const usage = lastMsg.usage; @@ -492,7 +508,13 @@ export function AssistantMessageGroup({
{messages.map((m) => ( - + ))} { }); }); +describe("canContinueMaxIterationsGroup", () => { + it("只允许最后一个包含 max_iterations 错误的助手组继续", () => { + const group = { + type: "assistant" as const, + messages: [makeMsg({ id: "a1", role: "assistant", errorCode: "max_iterations" })], + }; + expect(canContinueMaxIterationsGroup(group, true)).toBe(true); + expect(canContinueMaxIterationsGroup(group, false)).toBe(false); + expect( + canContinueMaxIterationsGroup( + { type: "assistant", messages: [makeMsg({ id: "a2", role: "assistant", content: "later" })] }, + true + ) + ).toBe(false); + }); +}); + describe("groupMessages", () => { it("用户和 assistant 消息交替分组", () => { const messages: ChatMessage[] = [ diff --git a/src/pages/options/routes/Agent/Chat/chat_utils.ts b/src/pages/options/routes/Agent/Chat/chat_utils.ts index 8ad8c7b06..0882d45f5 100644 --- a/src/pages/options/routes/Agent/Chat/chat_utils.ts +++ b/src/pages/options/routes/Agent/Chat/chat_utils.ts @@ -4,6 +4,14 @@ import type { SubAgentState } from "./types"; /** 消息分组:连续的 assistant 消息合并为一组,user 单独成组 */ export type MessageGroup = { type: "user"; message: ChatMessage } | { type: "assistant"; messages: ChatMessage[] }; +export function canContinueMaxIterationsGroup(group: MessageGroup, isLastGroup: boolean): boolean { + return ( + group.type === "assistant" && + isLastGroup && + group.messages.some((message) => message.errorCode === "max_iterations") + ); +} + /** 将 tool 角色消息的结果合并进 assistant 的 toolCalls,并过滤掉 tool/system 消息 */ export function mergeToolResults(messages: ChatMessage[]): ChatMessage[] { const toolResultMap = new Map(); diff --git a/src/pages/options/routes/Agent/Chat/hooks.test.ts b/src/pages/options/routes/Agent/Chat/hooks.test.ts index af010b877..6ffe5387b 100644 --- a/src/pages/options/routes/Agent/Chat/hooks.test.ts +++ b/src/pages/options/routes/Agent/Chat/hooks.test.ts @@ -23,9 +23,70 @@ vi.mock("@App/app/repo/skill_repo", () => ({ }, })); vi.mock("@App/pages/store/global", () => ({ message: {} })); -vi.mock("@Packages/message/client", () => ({ connect: vi.fn(), sendMessage: vi.fn(() => Promise.resolve([])) })); -import { useConversations, deleteMessages, clearMessages } from "./hooks"; +// 可控 mock 连接:测试直接驱动 onMessage 回调,模拟"stop 之后终态事件才到达"的真实时序 +function createMockConn() { + let messageHandler: ((msg: any) => void) | null = null; + let disconnectHandler: (() => void) | null = null; + return { + conn: { + sendMessage: vi.fn(), + onMessage: (cb: (msg: any) => void) => { + messageHandler = cb; + }, + onDisconnect: (cb: () => void) => { + disconnectHandler = cb; + }, + disconnect: vi.fn(), + }, + emit: (data: any) => messageHandler?.({ data }), + fireDisconnect: () => disconnectHandler?.(), + }; +} + +const mockConnect = vi.hoisted(() => vi.fn()); +vi.mock("@Packages/message/client", () => ({ + connect: mockConnect, + sendMessage: vi.fn(() => Promise.resolve([])), +})); + +import { useConversations, deleteMessages, clearMessages, useStreamingChat } from "./hooks"; + +describe("useStreamingChat:stop 后仍需放行终态事件(finding 6)", () => { + it("stopGeneration 之后到达的终态事件仍应触发 onDone 并断开连接,而不是被 abortedRef 吞掉", async () => { + const { conn, emit } = createMockConn(); + mockConnect.mockResolvedValue(conn); + + const { result } = renderHook(() => useStreamingChat()); + const onEvent = vi.fn(); + const onDone = vi.fn(); + + await act(async () => { + await result.current.sendMessage("conv-1", "hi", onEvent, onDone); + }); + + // 用户点击 Stop:只发 stop 消息、置 abortedRef,不立即断开 + act(() => { + result.current.stopGeneration(); + }); + expect(conn.sendMessage).toHaveBeenCalledWith({ action: "stop" }); + expect(conn.disconnect).not.toHaveBeenCalled(); + + // Stop 之后、真正终态事件到达之前的流式增量应被抑制 + act(() => { + emit({ type: "content_delta", delta: "不应该被处理" }); + }); + expect(onEvent).not.toHaveBeenCalledWith({ type: "content_delta", delta: "不应该被处理" }); + + // 真正的终态事件(携带取消原因/usage)到达:必须放行、断开连接、触发 onDone + act(() => { + emit({ type: "error", errorCode: "cancelled", message: "Conversation cancelled", usage: { inputTokens: 1 } }); + }); + expect(onEvent).toHaveBeenCalledWith(expect.objectContaining({ type: "error", errorCode: "cancelled" })); + expect(conn.disconnect).toHaveBeenCalledOnce(); + expect(onDone).toHaveBeenCalledOnce(); + }); +}); const conv = (id: string, title = "c"): Conversation => ({ id, diff --git a/src/pages/options/routes/Agent/Chat/hooks.ts b/src/pages/options/routes/Agent/Chat/hooks.ts index dac8f3690..76a548a5d 100644 --- a/src/pages/options/routes/Agent/Chat/hooks.ts +++ b/src/pages/options/routes/Agent/Chat/hooks.ts @@ -135,7 +135,14 @@ export function useMessages(conversationId: string) { } // ask_user 待回复状态 -export type AskUserPending = { id: string; question: string; options?: string[]; multiple?: boolean }; +export type AskUserPending = { + id: string; + question: string; + options?: string[]; + optionValues?: string[]; + multiple?: boolean; + allowCustom?: boolean; +}; // 流式聊天 hook export function useStreamingChat() { @@ -147,21 +154,18 @@ export function useStreamingChat() { const stopGeneration = useCallback(() => { abortedRef.current = true; const conn = connRef.current; - connRef.current = null; - // 先确保 UI 状态重置,再断开连接(避免 sendMessage/disconnect 抛异常导致状态卡住) - setIsStreaming(false); setAskUserPending(null); + // 不在这里把 isStreaming 置 false、也不立即断开连接——ChatArea 里"连接断开但 done + // 回调未触发时处理排队消息"的兜底逻辑正是监听 isStreaming 由 true 变 false 来触发的; + // 提前置为 false 会让排队消息在旧会话仍处于 cancelling(占用中)时就被处理,进而被拒绝 + // 且从未持久化就丢失(见 finding 6)。isStreaming 必须留到真正的终态事件到达时 + // (onMessage 的终态分支)或连接意外断开时(onDisconnect)才由那两处统一置 false。 if (conn) { try { conn.sendMessage({ action: "stop" }); } catch { // port 可能已断开 } - try { - conn.disconnect(); - } catch { - // port 可能已断开 - } } }, []); @@ -201,22 +205,33 @@ export function useStreamingChat() { connRef.current = conn; conn.onMessage((msg) => { - if (abortedRef.current) return; const event = msg.data as ChatStreamEvent; + const isTerminal = + (event.type === "done" || event.type === "error") && !("subAgent" in event && event.subAgent); + // stop 之后(abortedRef=true)必须继续放行终态事件——那条事件携带真正的取消原因/ + // usage/耗时,且负责断开连接、触发 onDone(进而处理排队消息);只需要抑制中间的 + // 流式增量/ask_user 事件,避免用户点了停止之后 UI 还在继续刷新内容(见 finding 6) + if (abortedRef.current && !isTerminal) return; // 处理 ask_user 事件 if (event.type === "ask_user") { setAskUserPending({ id: event.id, question: event.question, options: event.options, + optionValues: event.optionValues, multiple: event.multiple, + allowCustom: event.allowCustom, }); } + if (event.type === "ask_user_expired") setAskUserPending(null); + if (event.type === "ask_user_resolved") setAskUserPending(null); onEvent(event); - if ((event.type === "done" || event.type === "error") && !("subAgent" in event && event.subAgent)) { + if (isTerminal) { setIsStreaming(false); setAskUserPending(null); connRef.current = null; + // 终态事件后必须主动断开,否则 port/listener 会一直挂在 SW 侧,直到用户手动刷新页面 + conn.disconnect(); onDone(); } }); @@ -249,17 +264,26 @@ export function useStreamingChat() { connRef.current = conn; conn.onMessage((msg) => { - if (abortedRef.current) return; const event = msg.data as ChatStreamEvent; + const isTerminalEvent = + (event.type === "done" || event.type === "error") && !("subAgent" in event && event.subAgent); + // 与 sendMessage() 同理:stop 之后必须继续放行终态事件(含终态 sync), + // 否则会错过真正携带取消原因/usage 的那条事件,也无法触发 onDone 处理排队消息 + // (见 finding 6) + if (abortedRef.current && event.type !== "sync" && !isTerminalEvent) return; if (event.type === "ask_user") { setAskUserPending({ id: event.id, question: event.question, options: event.options, + optionValues: event.optionValues, multiple: event.multiple, + allowCustom: event.allowCustom, }); } + if (event.type === "ask_user_expired") setAskUserPending(null); + if (event.type === "ask_user_resolved") setAskUserPending(null); onEvent(event); @@ -274,15 +298,18 @@ export function useStreamingChat() { // done 或 error,无需保持连接 setIsStreaming(false); connRef.current = null; + // 终态 sync 后必须主动断开,否则 port/listener 会一直挂在 SW 侧 + conn.disconnect(); onDone(); } return; } - if ((event.type === "done" || event.type === "error") && !("subAgent" in event && event.subAgent)) { + if (isTerminalEvent) { setIsStreaming(false); setAskUserPending(null); connRef.current = null; + conn.disconnect(); onDone(); } }); diff --git a/src/pages/options/routes/Agent/Settings/index.test.tsx b/src/pages/options/routes/Agent/Settings/index.test.tsx index 526d01d8d..8f0972914 100644 --- a/src/pages/options/routes/Agent/Settings/index.test.tsx +++ b/src/pages/options/routes/Agent/Settings/index.test.tsx @@ -16,7 +16,10 @@ vi.mock("@App/pages/options/hooks/useScrollSpy", () => ({ }), })); -const { getSearchConfigMock } = vi.hoisted(() => ({ getSearchConfigMock: vi.fn() })); +const { getSearchConfigMock, getAgentConfigMock } = vi.hoisted(() => ({ + getSearchConfigMock: vi.fn(), + getAgentConfigMock: vi.fn(), +})); vi.mock("@App/pages/store/features/script", () => ({ agentClient: { listModels: vi.fn(async () => [ @@ -26,6 +29,8 @@ vi.mock("@App/pages/store/features/script", () => ({ getSearchConfig: getSearchConfigMock, setSummaryModelId: vi.fn(async () => {}), saveSearchConfig: vi.fn(async () => {}), + getAgentConfig: getAgentConfigMock, + saveAgentConfig: vi.fn(async () => {}), }, })); @@ -37,6 +42,7 @@ beforeAll(() => initTestLanguage("zh-CN")); beforeEach(() => { getSearchConfigMock.mockResolvedValue({ engine: "bing" }); + getAgentConfigMock.mockResolvedValue({ chatMaxIterations: 50 }); mockedUseIsMobile.mockReturnValue(false); }); afterEach(() => cleanup()); @@ -105,6 +111,43 @@ describe("AgentSettings 页面", () => { }); }); + describe("对话设置", () => { + it("挂载后展示已保存的最大工具调用次数", async () => { + render(); + const input = await screen.findByTestId("chat-max-iterations"); + expect(input).toHaveValue(50); + }); + + it("修改最大工具调用次数触发 saveAgentConfig", async () => { + render(); + const input = await screen.findByTestId("chat-max-iterations"); + fireEvent.change(input, { target: { value: "200" } }); + expect(agentClient.saveAgentConfig).toHaveBeenCalledWith({ chatMaxIterations: 200 }); + }); + + it("超过上限时应被截断为 1000", async () => { + render(); + const input = await screen.findByTestId("chat-max-iterations"); + fireEvent.change(input, { target: { value: "5000" } }); + expect(agentClient.saveAgentConfig).toHaveBeenCalledWith({ chatMaxIterations: 1000 }); + }); + + it("小于 1 时应被截断为 1", async () => { + render(); + const input = await screen.findByTestId("chat-max-iterations"); + fireEvent.change(input, { target: { value: "0" } }); + expect(agentClient.saveAgentConfig).toHaveBeenCalledWith({ chatMaxIterations: 1 }); + }); + + it("保存对话设置失败时提示错误", async () => { + vi.mocked(agentClient.saveAgentConfig).mockRejectedValueOnce(new Error("boom")); + render(); + const input = await screen.findByTestId("chat-max-iterations"); + await act(async () => fireEvent.change(input, { target: { value: "200" } })); + expect(notify.error).toHaveBeenCalledWith(t("agent:settings_save_failed")); + }); + }); + describe("移动端", () => { beforeEach(() => mockedUseIsMobile.mockReturnValue(true)); diff --git a/src/pages/options/routes/Agent/Settings/index.tsx b/src/pages/options/routes/Agent/Settings/index.tsx index b8294683b..7292be904 100644 --- a/src/pages/options/routes/Agent/Settings/index.tsx +++ b/src/pages/options/routes/Agent/Settings/index.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { BookOpen, Cpu, Info, Search, SlidersHorizontal, type LucideIcon } from "lucide-react"; +import { BookOpen, Cpu, Info, Repeat, Search, SlidersHorizontal, type LucideIcon } from "lucide-react"; import { notify } from "@App/pages/components/ui/toast"; import { cn } from "@App/pkg/utils/cn"; import { useScrollSpy } from "@App/pages/options/hooks/useScrollSpy"; @@ -14,8 +14,11 @@ import { Surface } from "@App/pages/components/ui/surface"; import { agentClient } from "@App/pages/store/features/script"; import type { AgentModelConfig } from "@App/app/service/agent/core/types"; import type { SearchEngineConfig } from "@App/app/service/agent/core/tools/search_config"; +import { DEFAULT_CHAT_MAX_ITERATIONS, type AgentGeneralConfig } from "@App/app/service/agent/core/agent_config"; const DEFAULT_MODEL = "__default__"; +const MIN_CHAT_MAX_ITERATIONS = 1; +const MAX_CHAT_MAX_ITERATIONS = 1000; type Engine = SearchEngineConfig["engine"]; @@ -94,21 +97,29 @@ export default function AgentSettings() { const [models, setModels] = useState([]); const [summaryModelId, setSummaryModelId] = useState(""); const [searchConfig, setSearchConfig] = useState({ engine: "bing" }); + const [agentConfig, setAgentConfig] = useState({ + chatMaxIterations: DEFAULT_CHAT_MAX_ITERATIONS, + }); const categories = [ { id: "model", icon: Cpu, label: t("agent:settings_cat_model") }, + { id: "conversation", icon: Repeat, label: t("agent:settings_cat_conversation") }, { id: "search", icon: Search, label: t("agent:settings_cat_search") }, ]; const { activeId, register, scrollContainerRef, scrollTo } = useScrollSpy(categories.map((c) => c.id)); useEffect(() => { - void Promise.all([agentClient.listModels(), agentClient.getSummaryModelId(), agentClient.getSearchConfig()]).then( - ([m, sid, sc]) => { - setModels(m); - setSummaryModelId(sid); - setSearchConfig(sc || { engine: "bing" }); - } - ); + void Promise.all([ + agentClient.listModels(), + agentClient.getSummaryModelId(), + agentClient.getSearchConfig(), + agentClient.getAgentConfig(), + ]).then(([m, sid, sc, ac]) => { + setModels(m); + setSummaryModelId(sid); + setSearchConfig(sc || { engine: "bing" }); + setAgentConfig(ac); + }); }, []); const handleSummaryChange = async (v: string) => { @@ -122,6 +133,20 @@ export default function AgentSettings() { } }; + const handleChatMaxIterationsChange = async (raw: string) => { + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return; + const clamped = Math.min(MAX_CHAT_MAX_ITERATIONS, Math.max(MIN_CHAT_MAX_ITERATIONS, Math.round(parsed))); + const next = { ...agentConfig, chatMaxIterations: clamped }; + setAgentConfig(next); + try { + await agentClient.saveAgentConfig(next); + notify.success(t("agent:settings_saved")); + } catch { + notify.error(t("agent:settings_save_failed")); + } + }; + const updateSearch = async (patch: Partial) => { const next = { ...searchConfig, ...patch }; setSearchConfig(next); @@ -254,6 +279,29 @@ export default function AgentSettings() { + + + handleChatMaxIterationsChange(e.target.value)} + /> + + + (promise: Promise, ms: number, onTimeoutError?: () => Error): Promise { - let timer: ReturnType; +export function withTimeout( + promise: Promise, + ms: number, + onTimeoutError?: () => Error, + signal?: AbortSignal +): Promise { + let timer: ReturnType | undefined; + let onAbort: (() => void) | undefined; const timeoutPromise = new Promise((_, reject) => { + if (signal?.aborted) { + reject(new Error("Aborted")); + return; + } + if (signal) { + onAbort = () => reject(new Error("Aborted")); + signal.addEventListener("abort", onAbort, { once: true }); + } timer = setTimeout(() => { reject(onTimeoutError ? onTimeoutError() : new Error("operation timed out")); }, ms); }); - return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer)); + return Promise.race([promise, timeoutPromise]).finally(() => { + clearTimeout(timer); + if (signal && onAbort) { + signal.removeEventListener("abort", onAbort); + } + }); } diff --git a/src/types/scriptcat.agent-background.d.ts b/src/types/scriptcat.agent-background.d.ts new file mode 100644 index 000000000..911497416 --- /dev/null +++ b/src/types/scriptcat.agent-background.d.ts @@ -0,0 +1,41 @@ +/** 英文与中文声明文件共用的后台会话扩展。 */ +declare namespace CATAgent { + /** 附加到后台会话时首先返回的状态快照。 */ + interface SyncStreamChunk { + type: "sync"; + /** 附加前已累计的 assistant 输出。 */ + streamingMessage?: { + content: string; + thinking?: string; + toolCalls: ToolCallInfo[]; + }; + /** 会话正在等待输入时的 ask_user 请求。 */ + pendingAskUser?: { + id: string; + question: string; + options?: string[]; + optionValues?: string[]; + multiple?: boolean; + allowCustom?: boolean; + }; + /** 当前任务快照。 */ + tasks: Array<{ + id: string; + subject: string; + status: "pending" | "in_progress" | "completed"; + description?: string; + }>; + /** 终态快照是 attach() 返回的最后一个数据块。 */ + status: "running" | "done" | "error"; + } + + interface ConversationCreateOptions { + /** 原始页面断开后仍在 Service Worker 中继续运行对话。 */ + background?: boolean; + } + + interface ConversationInstance { + /** 附加到后台会话,先接收必需快照,再接收后续流式数据。 */ + attach(): Promise>; + } +} diff --git a/src/types/scriptcat.d.ts b/src/types/scriptcat.d.ts index 85a45b9d0..dee9197c4 100644 --- a/src/types/scriptcat.d.ts +++ b/src/types/scriptcat.d.ts @@ -879,6 +879,8 @@ declare namespace CATAgent { ephemeral?: boolean; /** Enable prompt caching. Defaults to true. */ cache?: boolean; + /** Keep the conversation running in the Service Worker after the page disconnects. */ + background?: boolean; } /** Options for a single `chat()` / `chatStream()` call. */ @@ -930,7 +932,14 @@ declare namespace CATAgent { /** Tool calls made during this turn. */ toolCalls?: ToolCallInfo[]; /** Token usage. */ - usage?: { inputTokens: number; outputTokens: number }; + usage?: { + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens?: number; + cacheReadInputTokens?: number; + }; + /** Total response duration in ms. */ + durationMs?: number; /** `true` when the reply was produced by a command handler, not the LLM. */ command?: boolean; } @@ -941,20 +950,37 @@ declare namespace CATAgent { * Chunk type: * - `"content_delta"` — incremental text * - `"thinking_delta"` — incremental thinking/reasoning - * - `"tool_call"` — a tool call event + * - `"tool_call"` — a tool call event (start or argument delta) + * - `"tool_call_complete"` — a tool call finished executing, carries result/status/attachments * - `"content_block"` — a complete non-text content block + * - `"new_message"` — the current round ended, the next assistant message is about to start * - `"done"` — stream finished * - `"error"` — an error occurred */ - type: "content_delta" | "thinking_delta" | "tool_call" | "content_block" | "done" | "error"; + type: + | "content_delta" + | "thinking_delta" + | "tool_call" + | "tool_call_complete" + | "content_block" + | "new_message" + | "done" + | "error"; /** Text delta (for content_delta / thinking_delta). */ content?: string; /** Complete content block (for content_block). */ block?: ContentBlock; - /** Tool call info (for tool_call). */ + /** Tool call info (for tool_call / tool_call_complete). */ toolCall?: ToolCallInfo; /** Token usage (for done). */ - usage?: { inputTokens: number; outputTokens: number }; + usage?: { + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens?: number; + cacheReadInputTokens?: number; + }; + /** Total response duration in ms. */ + durationMs?: number; /** Error message (for error). */ error?: string; /** Error classification: `"rate_limit"` | `"auth"` | `"tool_timeout"` | `"max_iterations"` | `"api_error"` */ @@ -963,6 +989,35 @@ declare namespace CATAgent { command?: boolean; } + /** Initial snapshot returned when attaching to a background conversation. */ + interface SyncStreamChunk { + type: "sync"; + /** Assistant output accumulated before attach(). */ + streamingMessage?: { + content: string; + thinking?: string; + toolCalls: ToolCallInfo[]; + }; + /** Ask-user request when the conversation is waiting for input. */ + pendingAskUser?: { + id: string; + question: string; + options?: string[]; + optionValues?: string[]; + multiple?: boolean; + allowCustom?: boolean; + }; + /** Current task snapshot. */ + tasks: Array<{ + id: string; + subject: string; + status: "pending" | "in_progress" | "completed"; + description?: string; + }>; + /** Final snapshot status emitted by attach(). */ + status: "running" | "done" | "error"; + } + // ---- Chat message ---- /** A persisted chat message in a conversation. */ @@ -1024,6 +1079,9 @@ declare namespace CATAgent { /** Send a message and receive a streaming response. */ chatStream(content: MessageContent, options?: ChatOptions): Promise>; + /** Attach to a background conversation and receive the sync snapshot plus stream events. */ + attach(): Promise>; + /** Get all messages in this conversation. */ getMessages(): Promise; diff --git a/src/types/scriptcat.zh-CN.d.ts b/src/types/scriptcat.zh-CN.d.ts index 4c99999cb..cc53b73e8 100644 --- a/src/types/scriptcat.zh-CN.d.ts +++ b/src/types/scriptcat.zh-CN.d.ts @@ -3,7 +3,6 @@ // 此文件为 scriptcat.d.ts 的中文翻译版本,包含所有 GM_*/CAT_*/CAT.agent API。 // 如需接入,请在 tsconfig.json 中替换或追加此文件。 // ============================================================================ - // @copyright https://github.com/silverwzw/Tampermonkey-Typescript-Declaration declare const unsafeWindow: Window; @@ -886,6 +885,8 @@ declare namespace CATAgent { ephemeral?: boolean; /** 是否启用 prompt caching,默认 true。 */ cache?: boolean; + /** 在页面断开后仍让对话继续在 Service Worker 中运行。 */ + background?: boolean; } /** 单次 `chat()` / `chatStream()` 调用的选项。 */ @@ -937,7 +938,14 @@ declare namespace CATAgent { /** 本轮中的工具调用。 */ toolCalls?: ToolCallInfo[]; /** Token 用量。 */ - usage?: { inputTokens: number; outputTokens: number }; + usage?: { + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens?: number; + cacheReadInputTokens?: number; + }; + /** 总响应时长(毫秒)。 */ + durationMs?: number; /** 当回复由命令处理器产生(而非 LLM)时为 `true`。 */ command?: boolean; } @@ -948,20 +956,37 @@ declare namespace CATAgent { * 数据块类型: * - `"content_delta"` — 增量文本 * - `"thinking_delta"` — 增量思考/推理 - * - `"tool_call"` — 工具调用事件 + * - `"tool_call"` — 工具调用事件(开始或参数增量) + * - `"tool_call_complete"` — 工具调用执行完成,携带结果/状态/附件 * - `"content_block"` — 完整的非文本内容块 + * - `"new_message"` — 当前轮次结束,下一轮 assistant 消息即将开始 * - `"done"` — 流结束 * - `"error"` — 发生错误 */ - type: "content_delta" | "thinking_delta" | "tool_call" | "content_block" | "done" | "error"; + type: + | "content_delta" + | "thinking_delta" + | "tool_call" + | "tool_call_complete" + | "content_block" + | "new_message" + | "done" + | "error"; /** 文本增量(用于 content_delta / thinking_delta)。 */ content?: string; /** 完整内容块(用于 content_block)。 */ block?: ContentBlock; - /** 工具调用信息(用于 tool_call)。 */ + /** 工具调用信息(用于 tool_call / tool_call_complete)。 */ toolCall?: ToolCallInfo; /** Token 用量(用于 done)。 */ - usage?: { inputTokens: number; outputTokens: number }; + usage?: { + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens?: number; + cacheReadInputTokens?: number; + }; + /** 总响应时长(毫秒)。 */ + durationMs?: number; /** 错误信息(用于 error)。 */ error?: string; /** 错误分类码:`"rate_limit"` | `"auth"` | `"tool_timeout"` | `"max_iterations"` | `"api_error"` */ @@ -970,6 +995,35 @@ declare namespace CATAgent { command?: boolean; } + /** 附加到后台对话时返回的初始状态快照。 */ + interface SyncStreamChunk { + type: "sync"; + /** 附加前已累计的 assistant 输出。 */ + streamingMessage?: { + content: string; + thinking?: string; + toolCalls: ToolCallInfo[]; + }; + /** 会话正在等待输入时的 ask_user 请求。 */ + pendingAskUser?: { + id: string; + question: string; + options?: string[]; + optionValues?: string[]; + multiple?: boolean; + allowCustom?: boolean; + }; + /** 当前任务快照。 */ + tasks: Array<{ + id: string; + subject: string; + status: "pending" | "in_progress" | "completed"; + description?: string; + }>; + /** attach() 返回的最终快照状态。 */ + status: "running" | "done" | "error"; + } + // ---- 聊天消息 ---- /** 对话中持久化的聊天消息。 */ @@ -1031,6 +1085,9 @@ declare namespace CATAgent { /** 发送消息并接收流式响应。 */ chatStream(content: MessageContent, options?: ChatOptions): Promise>; + /** 附加到后台运行中的对话,接收初始快照与后续流式数据。 */ + attach(): Promise>; + /** 获取此对话中的所有消息。 */ getMessages(): Promise;