From c6181b51b0e0a289e90760af07fd5b594d83c996 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 7 Jul 2026 02:24:16 +0900 Subject: [PATCH 01/38] =?UTF-8?q?=E2=9C=A8=20Agent=20=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E6=9C=80=E5=A4=A7=E5=B7=A5=E5=85=B7=E8=B0=83=E7=94=A8=E6=AC=A1?= =?UTF-8?q?=E6=95=B0=E5=8F=AF=E9=85=8D=E7=BD=AE=20+=20=E8=BE=BE=E4=B8=8A?= =?UTF-8?q?=E9=99=90=E5=90=8E=E5=8F=AF=E7=BB=A7=E7=BB=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 针对 #1545:UI 对话此前硬编码 50 次工具调用上限且无法调整,达到上限 后用户误以为必须新建对话、丢失已探索的上下文。 - 新增 AgentConfigRepo(chatMaxIterations,默认 50,1-1000),Settings 页新增"对话"分类可视化配置 - 达到 max_iterations 时持久化 errorCode,聊天界面据此渲染"继续对话" 按钮,一键复用已持久化的完整历史继续执行 - 全部 8 个 locale 补齐相应文案 Co-Authored-By: Claude Sonnet 5 --- issue-1545-proposal.md | 130 ++++++++++++++++++ .../service/agent/core/agent_config.test.ts | 43 ++++++ src/app/service/agent/core/agent_config.ts | 29 ++++ src/app/service/agent/core/types.ts | 2 + src/app/service/agent/service_worker/agent.ts | 8 +- .../agent/service_worker/chat_service.ts | 12 +- .../service_worker/tool_loop_orchestrator.ts | 1 + src/app/service/service_worker/client.ts | 10 ++ src/locales/de-DE/agent.json | 5 + src/locales/en-US/agent.json | 5 + src/locales/ja-JP/agent.json | 5 + src/locales/ru-RU/agent.json | 5 + src/locales/tr-TR/agent.json | 5 + src/locales/vi-VN/agent.json | 5 + src/locales/zh-CN/agent.json | 5 + src/locales/zh-TW/agent.json | 5 + .../options/routes/Agent/Chat/ChatArea.tsx | 1 + .../routes/Agent/Chat/MessageItem.test.tsx | 31 +++++ .../options/routes/Agent/Chat/MessageItem.tsx | 26 +++- .../routes/Agent/Settings/index.test.tsx | 45 +++++- .../options/routes/Agent/Settings/index.tsx | 64 +++++++-- 21 files changed, 427 insertions(+), 15 deletions(-) create mode 100644 issue-1545-proposal.md create mode 100644 src/app/service/agent/core/agent_config.test.ts create mode 100644 src/app/service/agent/core/agent_config.ts diff --git a/issue-1545-proposal.md b/issue-1545-proposal.md new file mode 100644 index 000000000..85b99a291 --- /dev/null +++ b/issue-1545-proposal.md @@ -0,0 +1,130 @@ +# Technical Proposal — Issue #1545: Agent maxIterations Configuration, Context Compression & Loop Guard + +> Issue: https://github.com/scriptscat/scriptcat/issues/1545 +> Branch analyzed: `feat/ai-001` +> Status: proposal (no code changes yet) + +The issue raises three pain points when using the built-in AI Agent for heavy tool-calling workloads (Webpack reverse-engineering, repeated `execute_script` probing): + +1. The 50-iteration hard limit cannot be raised from the UI. +2. Token consumption is very high because full tool outputs are retained across dozens of rounds. +3. No loop guard pauses the agent when it repeats near-identical failing calls. + +This document maps each ask against what already exists in the codebase, identifies the real gaps, and proposes a phased implementation. + +--- + +## 1. Current implementation — findings + +### 1.1 Iteration limits are real, hardcoded per entry point, and not exposed for UI chat + +The unified loop lives in `ToolLoopOrchestrator.callLLMWithToolLoop()` — `src/app/service/agent/service_worker/tool_loop_orchestrator.ts:85` (`while (iterations < maxIterations)`). On exhaustion it persists an error message and emits `errorCode: "max_iterations"` (`tool_loop_orchestrator.ts:271-287`), which is exactly the error quoted in the issue. + +Each caller supplies its own default: + +| Entry point | Default | User-configurable today? | Source | +|---|---|---|---| +| UI chat (options panel) | **50** | **No** — UI never sends the param | `chat_service.ts:319` (`params.maxIterations \|\| 50`); the chat payload built in `src/pages/options/routes/Agent/Chat/hooks.ts:192-199` has no `maxIterations` field | +| Userscript `CAT.agent.conversation` (ephemeral) | 20 | Yes, via `ConversationCreateOptions.maxIterations` | `chat_service.ts:398`, `src/app/service/agent/core/types.ts:219-230` | +| Scheduled internal tasks | 10 | **Yes — already has a UI field** | `task_service.ts:170`, `src/pages/options/routes/Agent/Tasks/TaskFormDialog.tsx:176-184` | +| Sub-agents (researcher / page_operator / general) | 20 / 30 / 30 | No | `src/app/service/agent/core/sub_agent_types.ts:33,68,89` | + +So the issue's request ("expose `maxIterations` in settings, inject into `ConversationCreateOptions`") is: (a) already satisfied for userscripts and scheduled tasks, (b) genuinely missing **only for the UI chat panel** — the highest-traffic path. + +**Important correction to the issue's "Alternatives" section:** hitting the limit does *not* lose the conversation. All messages (including every tool round) are persisted via `chatRepo.appendMessage` during the loop, so sending another message (e.g. "继续") resumes with a fresh 50-iteration budget and full history. The perceived "must start a new conversation and lose everything" is a UX/communication gap, not a data loss — nothing in the UI tells the user this. This makes a "Continue" affordance on the `max_iterations` error extremely cheap and high-value. + +### 1.2 Context compression exists, but is all-or-nothing, and the biggest token leak is elsewhere + +What exists: + +- **Auto-compact**: when `inputTokens / contextWindow ≥ 0.8`, the whole history is summarized by the LLM and replaced with a single `[Conversation Summary]` message (`tool_loop_orchestrator.ts:113-121`, `compact_service.ts:50-96`, window sizes from `core/model_context.ts`). It fires only at 80% — for a 200K-window model that is ~160K tokens *per subsequent request*, long after the cost pain the issue describes. +- **Per-tool truncation is inconsistent**: `get_tab_content` truncates at `maxLength` and can route through the summary model (`core/tools/tab_tools.ts:169-186`), but **`execute_script` returns `JSON.stringify(result)` unbounded** (`core/tools/execute_script.ts:61,70`). A script that returns a DOM dump or module map injects megabytes into the context, and it is then re-sent on *every* remaining iteration. This is precisely the issue's scenario. +- **Tool results are never pruned**: every round appends full results (`tool_loop_orchestrator.ts:166`) and the array only shrinks at the 80% compact cliff. + +The largest single cost factor, however, is a **prompt-caching gap**: the Anthropic provider sets `cache_control` only on the system prompt and tool definitions (`core/providers/anthropic.ts:139-141,153-155`) — **never on conversation messages**. In a 50-iteration loop the ever-growing message history is billed at the full input rate on every round, roughly O(n²) tokens per task. A moving cache breakpoint on the last message would convert the shared prefix of each round into cache reads (~10% of the input price on Anthropic). This change alone likely delivers more savings than any summarization scheme, with zero behavior change. + +### 1.3 A loop guard already exists — but only nudges the model, never the user + +`src/app/service/agent/core/tool_call_guard.ts` runs every round (`tool_loop_orchestrator.ts:237-242`) with four heuristics: identical tool+args called twice, `execute_script` returning null 3× consecutively, `get_tab_content` on the same tab 3×, and any tool ≥5 times in the last 8 calls. On detection it injects a `[System Warning]` user message and emits a `system_warning` UI event (rendered at `ChatArea.tsx:377`). + +Gap vs. the issue's ask: warnings only steer the LLM; if the model ignores them, tokens keep burning. There is no pause-and-confirm. The building blocks for one already exist: the `ask_user` tool (`core/tools/ask_user.ts`), the `askResolvers` plumbing in `chat_service.ts`, and the pending-question UI in `hooks.ts:169-174`. + +--- + +## 2. Proposal + +### Phase 1 — Configurable max iterations + "Continue" affordance (core ask, small) + +**2.1 `AgentConfigRepo`.** Add a small repo following the existing `SearchConfigRepo` pattern (`core/tools/search_config.ts`, `chrome.storage.local`, key `agent_config`): + +```ts +export type AgentGeneralConfig = { + chatMaxIterations: number; // default 50, clamp 1–1000 +}; +``` + +Expose `getAgentConfig` / `saveAgentConfig` via `Group.on(...)` in `agent.ts` next to the existing `getSearchConfig`/`saveSearchConfig` handlers (`agent.ts:215-216`), and mirror them on `agentClient`. + +**2.2 Wire into the chat path.** In `chat_service.ts:319`, resolve the limit as `params.maxIterations ?? config.chatMaxIterations ?? 50`. Explicit per-call values (userscripts, tasks, sub-agents) keep precedence; their defaults stay 20/10/20-30 — this setting governs UI chat only. (Optionally a later iteration can apply the same config as the *default* for `ConversationCreateOptions` when a script omits it, as the issue suggests, but that changes script-facing defaults and should be a separate, documented decision.) + +**2.3 Settings UI.** New "Conversation" (对话) card on the Agent Settings page (`src/pages/options/routes/Agent/Settings/index.tsx`), one numeric `SettingsField` mirroring the task dialog's `task-max-iter` input. The description carries the cost warning the issue itself proposes ("调大此选项可能会增加单次任务的 Token 开销"). i18n keys go into `src/locales//agent.json` for all 8 locales, following `docs/translation/README.md` — no `defaultValue` fallbacks (banned by `scriptcat/no-i18n-default-value`). + +**2.4 "Continue" button on the `max_iterations` error.** In the chat error rendering (`MessageItem.tsx:73-76` / `ChatArea.tsx`), when the persisted message's error code is `max_iterations`, show a one-click "继续 / Continue" action that re-invokes the existing `sendMessage` with a fixed continue prompt. Because history is already persisted (see §1.1), this fully solves the "lost 30 rounds of reverse-engineering context" complaint without any unlimited mode. + +**On "unlimited" mode:** recommend **not** shipping a literal unlimited option. A bounded cap (up to 1000) plus the Continue button plus Phase 3 checkpoints gives the same capability with a safety floor. Unbounded loops in a background session (`background_session_manager.ts`) with a stuck model have no natural stop. + +### Phase 2 — Token diet (highest ROI first) + +**2.5 Message-history prompt caching (do this first).** In `core/providers/anthropic.ts`, add a `cache_control: { type: "ephemeral" }` breakpoint to the content of the last message (and optionally the second-to-last, staying within Anthropic's 4-breakpoint limit alongside system + tools). Each loop iteration then pays cache-read for the entire shared prefix instead of full input price. No behavior change, no UI, immediately addresses "单次逆向流程调试花费近 3 元". OpenAI-compatible providers already benefit from automatic prefix caching; the stable message ordering required is already in place. + +**2.6 Cap `execute_script` output at the source.** In `core/tools/execute_script.ts`, truncate the stringified result beyond a threshold (suggest 30 000 chars ≈ 8K tokens) with head + tail retention and an explicit marker (`…[truncated 412 kB — return a smaller value or write to OPFS via opfs_write]`), mirroring the `get_tab_content` precedent (`tab_tools.ts:169-186`). This is the single biggest defense for the issue's exact workload. Apply the same generic cap in `ToolLoopOrchestrator` before `messages.push({ role: "tool", ... })` as a backstop for MCP/skill tools. The *persisted* message keeps the (already produced) full text so the UI can still show it; only the LLM context is capped. + +**2.7 Sliding-window elision of old tool results.** When building each round's request, replace `role: "tool"` contents older than the last K assistant turns (suggest K = 5) with a stub (`[tool result elided — re-run the tool if needed]`), keeping assistant text and tool-call names intact so the reasoning trail survives. Two constraints learned from the current code: + +- **Prune the in-flight `messages` array only** — never the repo. Persistence and UI history stay complete (same separation `autoCompact` already uses). +- **Batch the elision at thresholds** (e.g. at 40% / 60% context usage), not every round — every prefix rewrite invalidates the Phase 2.5 prompt cache, so continuous pruning would cost more than it saves. Threshold-stepped pruning keeps long stable prefixes between rewrites. + +Additionally, lower the compact trigger from 80% to ~70% and consider keeping the last K rounds verbatim after compact (today `autoCompact` replaces *everything* with the summary, `compact_service.ts:78-82`, dropping fresh tool state the model still needs). + +### Phase 3 — Loop-guard escalation to the user (the issue's point 3) + +**2.8 Escalating guard.** Extend `detectToolCallIssues` (or wrap it in the orchestrator) with a strike counter per conversation. Today each warning advances `guardStartIndex` and the loop continues unconditionally (`tool_loop_orchestrator.ts:237-242`). Proposed: after the 2nd guard hit in one run, pause the loop and ask the user via the existing `ask_user` machinery ("Agent 疑似陷入循环(已重复调用 execute_script N 次且结果相似),是否继续?[继续 / 停止]"). On "stop", end the run gracefully (persist partial result) instead of erroring. + +**2.9 Iteration checkpoints.** Optional setting `iterationCheckpoint` (e.g. every 25 rounds): the loop pauses and asks "已连续执行 25 次工具调用,是否继续?" — matching the competitor behavior cited in the issue. Rules: + +- **UI chat only.** Scheduled tasks (`task_service.ts`) and sub-agents must never block on a human; they keep hard caps. +- **Background sessions** (UI detached, `background_session_manager.ts`): auto-continue with a logged `system_warning`, or auto-stop per setting — an unanswerable question must not hang the service worker. + +### Out of scope (rejected alternatives) + +- **Similarity-based ML loop detection**: the existing four heuristics plus escalation cover the practical cases; embedding-based similarity adds latency and a model dependency for marginal gain. +- **Client-side token pre-counting** for exact budget UI: provider tokenizers differ; the existing `usage`-driven ratio is sufficient. + +--- + +## 3. Compatibility & security + +- All changes are additive. Existing defaults (50 / 20 / 10 / 20-30) are unchanged; the userscript API surface (`ConversationCreateOptions`) is untouched in Phase 1. +- Upper clamp (1000) plus checkpoint confirmations bound worst-case spend; the settings field carries the token-cost warning text the issue requested. +- Truncation caps reduce, never increase, what leaves the browser toward LLM providers. +- Cache breakpoints send no additional data; `cache: false` callers (e.g. compact runs, `compact_service.ts:71`) are unaffected. + +## 4. Test plan (TDD, per `AGENTS.md`) + +Failing tests first, BDD-style Chinese titles, extending existing suites: + +1. `tool_loop_orchestrator` (`agent.test.ts` / new spec): reads config default when caller omits `maxIterations`; emits checkpoint `ask_user` at N; guard escalation pauses after 2nd strike; elision keeps last K tool results and repo messages intact. +2. `execute_script` truncation: oversized result capped with marker; small results untouched. +3. `anthropic` provider (`llm.test.ts` pattern): `cache_control` present on last message block when `cache !== false`, absent otherwise, ≤4 breakpoints total. +4. Settings UI (`Settings/index.test.tsx` pattern): field renders, clamps 1–1000, persists via `agentClient`. +5. Chat UI: `max_iterations` error renders Continue action; clicking resumes the conversation. + +## 5. Suggested rollout + +| Phase | Items | Size | User-visible effect | +|---|---|---|---| +| 1 | Config repo + settings field + wire-in + Continue button | S | Unblocks the issue author immediately | +| 2 | Cache breakpoints → execute_script cap → sliding window | M | 5–10× cost reduction on long tool loops | +| 3 | Guard escalation + iteration checkpoints | M | Stops dead loops before the budget burns | + +Phases are independently shippable; Phase 2.5 (cache breakpoints) is the best effort-to-savings ratio in the whole proposal and could even ship first. 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..9d3be6604 --- /dev/null +++ b/src/app/service/agent/core/agent_config.test.ts @@ -0,0 +1,43 @@ +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 }); + }); + }); +}); 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..98d8cf70c --- /dev/null +++ b/src/app/service/agent/core/agent_config.ts @@ -0,0 +1,29 @@ +// 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; + +const DEFAULT_CONFIG: AgentGeneralConfig = { + chatMaxIterations: DEFAULT_CHAT_MAX_ITERATIONS, +}; + +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 { ...DEFAULT_CONFIG, ...stored }; + } catch { + return DEFAULT_CONFIG; + } + } + + async saveConfig(config: AgentGeneralConfig): Promise { + await chrome.storage.local.set({ [STORAGE_KEY]: config }); + } +} diff --git a/src/app/service/agent/core/types.ts b/src/app/service/agent/core/types.ts index 54574d295..5da995ae6 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; diff --git a/src/app/service/agent/service_worker/agent.ts b/src/app/service/agent/service_worker/agent.ts index 37661d45d..195aa8f5c 100644 --- a/src/app/service/agent/service_worker/agent.ts +++ b/src/app/service/agent/service_worker/agent.ts @@ -36,6 +36,7 @@ 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"; @@ -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 @@ -133,7 +135,8 @@ export class AgentService { callLLM: (model, params, sendEvent, signal) => this.callLLM(model, params, sendEvent, signal), callLLMWithToolLoop: (params) => this.callLLMWithToolLoop(params), }, - agentChatRepo + agentChatRepo, + this.agentConfigRepo ); } @@ -214,6 +217,9 @@ 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, diff --git a/src/app/service/agent/service_worker/chat_service.ts b/src/app/service/agent/service_worker/chat_service.ts index 8ce7a833b..bef80cea5 100644 --- a/src/app/service/agent/service_worker/chat_service.ts +++ b/src/app/service/agent/service_worker/chat_service.ts @@ -12,6 +12,7 @@ import type { 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 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"; @@ -98,7 +99,8 @@ export class ChatService { private subAgentService: SubAgentService, private executeScriptDeps: ChatServiceExecuteScriptDeps, private llmDeps: ChatServiceLLMDeps, - private chatRepo: AgentChatRepo + private chatRepo: AgentChatRepo, + private agentConfigRepo: AgentConfigRepo ) {} // 处理 Sandbox conversation API 请求(非流式) @@ -281,6 +283,8 @@ export class ChatService { } 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({ @@ -316,7 +320,7 @@ export class ChatService { model, messages, tools: enableTools ? params.tools : undefined, - maxIterations: params.maxIterations || 50, + maxIterations: params.maxIterations || agentConfig.chatMaxIterations, sendEvent, signal: abortController.signal, scriptToolCallback: enableTools && params.tools && params.tools.length > 0 ? scriptToolCallback : null, @@ -337,6 +341,7 @@ export class ChatService { return; } const errorMsg = e.message || "Unknown error"; + const errorCode = classifyErrorCode(e); // 持久化错误消息到 OPFS,确保刷新后仍可见 if (params.conversationId && !params.ephemeral) { try { @@ -346,13 +351,14 @@ export class ChatService { role: "assistant", content: "", error: errorMsg, + errorCode, createtime: Date.now(), }); } catch { // 持久化失败不阻塞错误事件发送 } } - sendEvent({ type: "error", message: errorMsg, errorCode: classifyErrorCode(e) }); + sendEvent({ type: "error", message: errorMsg, errorCode }); this.bgSessionManager.cleanupIfDone(params.conversationId); } } 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..7f4ecdcb5 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator.ts @@ -277,6 +277,7 @@ export class ToolLoopOrchestrator { role: "assistant", content: "", error: maxIterMsg, + errorCode: "max_iterations", createtime: Date.now(), }); } diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index 5a2a4bbf1..2e6cef7dd 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -14,6 +14,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, @@ -471,6 +472,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..0f0149c65 100644 --- a/src/locales/de-DE/agent.json +++ b/src/locales/de-DE/agent.json @@ -86,6 +86,8 @@ "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_loading_audio": "Audio wird geladen...", "chat_starting": "Wird gestartet...", "chat_retrying": "Wiederholung läuft ({{attempt}}/{{max}})...", @@ -170,10 +172,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..2d75d5a25 100644 --- a/src/locales/en-US/agent.json +++ b/src/locales/en-US/agent.json @@ -86,6 +86,8 @@ "chat_copy_success": "Copied", "chat_regenerate": "Regenerate", "chat_streaming": "Generating...", + "chat_continue_max_iterations": "Continue Conversation", + "chat_continue_message": "Please continue.", "chat_loading_audio": "Loading audio...", "chat_starting": "Starting...", "chat_retrying": "Retrying ({{attempt}}/{{max}})...", @@ -203,10 +205,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..561866a03 100644 --- a/src/locales/ja-JP/agent.json +++ b/src/locales/ja-JP/agent.json @@ -86,6 +86,8 @@ "chat_copy_success": "コピーしました", "chat_regenerate": "再生成", "chat_streaming": "生成中...", + "chat_continue_max_iterations": "会話を続ける", + "chat_continue_message": "続けてください。", "chat_loading_audio": "音声を読み込み中...", "chat_starting": "起動中...", "chat_retrying": "再試行中 ({{attempt}}/{{max}})...", @@ -170,10 +172,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..24dcf83f9 100644 --- a/src/locales/ru-RU/agent.json +++ b/src/locales/ru-RU/agent.json @@ -86,6 +86,8 @@ "chat_copy_success": "Скопировано", "chat_regenerate": "Сгенерировать заново", "chat_streaming": "Генерация...", + "chat_continue_max_iterations": "Продолжить беседу", + "chat_continue_message": "Пожалуйста, продолжайте.", "chat_loading_audio": "Загрузка аудио...", "chat_starting": "Запуск...", "chat_retrying": "Повтор ({{attempt}}/{{max}})...", @@ -170,10 +172,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 8215c5e9d..717e7f478 100644 --- a/src/locales/tr-TR/agent.json +++ b/src/locales/tr-TR/agent.json @@ -86,6 +86,8 @@ "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_loading_audio": "Ses yükleniyor...", "chat_starting": "Başlatılıyor...", "chat_retrying": "Yeniden deneniyor ({{attempt}}/{{max}})...", @@ -203,10 +205,13 @@ "settings_title": "Agent 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 modeli, ayarlanmazsa varsayılana geri döner", "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 alma kaynağı.", "search_engine_baidu": "Baidu", diff --git a/src/locales/vi-VN/agent.json b/src/locales/vi-VN/agent.json index 74115f117..ec03bdab7 100644 --- a/src/locales/vi-VN/agent.json +++ b/src/locales/vi-VN/agent.json @@ -86,6 +86,8 @@ "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_loading_audio": "Đang tải âm thanh...", "chat_starting": "Đang khởi động...", "chat_retrying": "Đang thử lại ({{attempt}}/{{max}})...", @@ -170,10 +172,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..c8cb6ca0a 100644 --- a/src/locales/zh-CN/agent.json +++ b/src/locales/zh-CN/agent.json @@ -86,6 +86,8 @@ "chat_copy_success": "已复制", "chat_regenerate": "重新生成", "chat_streaming": "生成中...", + "chat_continue_max_iterations": "继续对话", + "chat_continue_message": "请继续。", "chat_loading_audio": "正在加载音频...", "chat_starting": "正在启动...", "chat_retrying": "正在重试 ({{attempt}}/{{max}})...", @@ -203,10 +205,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..8f178eaf4 100644 --- a/src/locales/zh-TW/agent.json +++ b/src/locales/zh-TW/agent.json @@ -86,6 +86,8 @@ "chat_copy_success": "已複製", "chat_regenerate": "重新生成", "chat_streaming": "生成中...", + "chat_continue_max_iterations": "繼續對話", + "chat_continue_message": "請繼續。", "chat_loading_audio": "正在載入音訊...", "chat_starting": "正在啟動...", "chat_retrying": "正在重試 ({{attempt}}/{{max}})...", @@ -170,10 +172,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/ChatArea.tsx b/src/pages/options/routes/Agent/Chat/ChatArea.tsx index 67f63acbd..0a7832bcb 100644 --- a/src/pages/options/routes/Agent/Chat/ChatArea.tsx +++ b/src/pages/options/routes/Agent/Chat/ChatArea.tsx @@ -731,6 +731,7 @@ export default function ChatArea({ onCopy={() => handleCopy(group.messages)} onRegenerate={() => handleRegenerate(messageGroups, groupIndex)} onDelete={() => handleDeleteRound(messageGroups, groupIndex)} + onContinue={isStreaming ? undefined : () => void handleSend(t("agent:chat_continue_message"))} /> ) ) 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) => ( - + ))} ({ }), })); -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)} + /> + + + Date: Tue, 7 Jul 2026 02:29:14 +0900 Subject: [PATCH 02/38] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20Anthropic=20?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E5=8E=86=E5=8F=B2=E7=BC=93=E5=AD=98=E6=96=AD?= =?UTF-8?q?=E7=82=B9=20+=20execute=5Fscript=20=E8=B6=85=E5=A4=A7=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=E5=80=BC=E6=88=AA=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 针对 #1545 的 Token 消耗问题:长 tool loop 每轮都全量计费输入 token, 且 execute_script 返回值(如 DOM dump、模块映射)不设上限,被完整保留 并在后续每轮重复发送。 - Anthropic provider 为最后一条消息追加 cache_control 断点,使已产生的 历史前缀被缓存,下一轮仅新增部分计费(system/tools 断点已存在,此为 第三个断点,未超过 4 个断点上限) - execute_script 返回值超过 30000 字符时截断为首尾各 15000 字符并标注 truncated / original_length,避免超大返回值反复占用上下文 Co-Authored-By: Claude Sonnet 5 --- .../agent/core/providers/anthropic.test.ts | 90 +++++++++++++++++++ .../service/agent/core/providers/anthropic.ts | 15 ++++ .../agent/core/tools/execute_script.test.ts | 43 +++++++++ .../agent/core/tools/execute_script.ts | 24 ++++- 4 files changed, 170 insertions(+), 2 deletions(-) diff --git a/src/app/service/agent/core/providers/anthropic.test.ts b/src/app/service/agent/core/providers/anthropic.test.ts index 36697e7b9..8d4568845 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 ], }; diff --git a/src/app/service/agent/core/providers/anthropic.ts b/src/app/service/agent/core/providers/anthropic.ts index 27486e164..5682f57a5 100644 --- a/src/app/service/agent/core/providers/anthropic.ts +++ b/src/app/service/agent/core/providers/anthropic.ts @@ -117,6 +117,21 @@ 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, 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..b3b07da79 100644 --- a/src/app/service/agent/core/tools/execute_script.test.ts +++ b/src/app/service/agent/core/tools/execute_script.test.ts @@ -117,6 +117,49 @@ describe("execute_script 工具", () => { }); }); + describe("超大返回值截断", () => { + 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(); + }); + }); + describe("错误传播", () => { it.concurrent("page 模式执行错误应传播", async () => { const mockExecuteInPage = vi.fn().mockRejectedValue(new Error("No active tab found")); diff --git a/src/app/service/agent/core/tools/execute_script.ts b/src/app/service/agent/core/tools/execute_script.ts index 8091ef9f0..b1ca8f370 100644 --- a/src/app/service/agent/core/tools/execute_script.ts +++ b/src/app/service/agent/core/tools/execute_script.ts @@ -30,6 +30,26 @@ export const EXECUTE_SCRIPT_DEFINITION: ToolDefinition = { const EXECUTE_SCRIPT_TIMEOUT_MS = 30_000; +// 返回值过大时(如 DOM dump、模块映射)截断,避免其在后续每轮 tool loop 中被完整重复计费 +const MAX_RESULT_CHARS = 30_000; +const HEAD_CHARS = 15_000; +const TAIL_CHARS = 15_000; + +/** 将 result 序列化,超过阈值时截断为首尾各一部分并标注 truncated */ +function buildResultPayload(result: unknown, extra: Record): string { + const rawResult = result ?? null; + const resultStr = JSON.stringify(rawResult); + if (resultStr.length <= MAX_RESULT_CHARS) { + return JSON.stringify({ result: rawResult, ...extra }); + } + const omitted = resultStr.length - HEAD_CHARS - TAIL_CHARS; + const truncatedText = + resultStr.slice(0, HEAD_CHARS) + + `\n…[truncated ${omitted} chars — return a smaller value or write large data to OPFS via opfs_write]…\n` + + resultStr.slice(resultStr.length - TAIL_CHARS); + return JSON.stringify({ result: truncatedText, ...extra, truncated: true, original_length: resultStr.length }); +} + export type ExecuteScriptDeps = { executeInPage: (code: string, options?: { tabId?: number }) => Promise<{ result: unknown; tabId: number }>; executeInSandbox: (code: string) => Promise; @@ -58,7 +78,7 @@ export function createExecuteScriptTool(deps: ExecuteScriptDeps): { timeoutMs, () => new Error(`execute_script timed out after ${timeoutMs / 1000}s`) ); - return JSON.stringify({ result: result ?? null, target: "page", tab_id: actualTabId }); + return buildResultPayload(result, { target: "page", tab_id: actualTabId }); } // sandbox @@ -67,7 +87,7 @@ export function createExecuteScriptTool(deps: ExecuteScriptDeps): { timeoutMs, () => new Error(`execute_script timed out after ${timeoutMs / 1000}s`) ); - return JSON.stringify({ result: result ?? null, target: "sandbox" }); + return buildResultPayload(result, { target: "sandbox" }); }, }; From e6d5a5033164b35f2ba77a2c564ff9263ba30bf3 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 7 Jul 2026 02:37:39 +0900 Subject: [PATCH 03/38] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20=E9=95=BF=20tool=20l?= =?UTF-8?q?oop=20=E5=88=86=E6=89=B9=E8=A3=81=E5=89=AA=E6=97=A7=20tool=20?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=EF=BC=88=E6=BB=91=E5=8A=A8=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 针对 #1545:在触及 80% 的 autoCompact 阈值之前,长对话的旧 tool 结果 (如 DOM dump)会在后续每一轮都被完整重复发送,进一步放大 Token 消耗。 - 新增 elideOldToolResults:保留最近 5 轮 assistant/tool 消息原文, 更早的 tool 结果替换为占位文本;只裁剪内存中传给 LLM 的消息,不影响 chatRepo 持久化与 UI 历史 - 在 40% / 60% 两个上下文占用阈值各触发一次(而非逐轮触发),避免 频繁重写消息前缀导致 Anthropic 的 prompt cache 断点失效 Co-Authored-By: Claude Sonnet 5 --- .../agent/core/context_elision.test.ts | 92 +++++++++++++++++++ src/app/service/agent/core/context_elision.ts | 32 +++++++ .../service/agent/service_worker/llm.test.ts | 77 ++++++++++++++++ .../service_worker/tool_loop_orchestrator.ts | 24 ++++- 4 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 src/app/service/agent/core/context_elision.test.ts create mode 100644 src/app/service/agent/core/context_elision.ts 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..4e1fe85f2 --- /dev/null +++ b/src/app/service/agent/core/context_elision.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { elideOldToolResults, ELIDED_TOOL_RESULT_STUB } from "./context_elision"; +import type { ChatRequest } from "./types"; + +// 构造一轮 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); + }); +}); 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..dfb61d3b3 --- /dev/null +++ b/src/app/service/agent/core/context_elision.ts @@ -0,0 +1,32 @@ +// 滑动窗口裁剪:仅裁剪内存中传给 LLM 的 messages(不影响 chatRepo 持久化/UI 历史), +// 用于在触及 autoCompact 的 80% 阈值之前,减少长 tool loop 中旧 tool 结果的重复计费。 +import type { ChatRequest } from "./types"; + +export const ELIDED_TOOL_RESULT_STUB = + "[tool result elided to save context — re-run the tool if you need this data again]"; + +/** + * 保留最近 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; + } + } + } + + for (let i = 0; i < cutoffIndex; i++) { + const m = messages[i]; + if (m.role === "tool" && m.content !== ELIDED_TOOL_RESULT_STUB) { + m.content = ELIDED_TOOL_RESULT_STUB; + } + } +} diff --git a/src/app/service/agent/service_worker/llm.test.ts b/src/app/service/agent/service_worker/llm.test.ts index ff480bf9b..c092c2350 100644 --- a/src/app/service/agent/service_worker/llm.test.ts +++ b/src/app/service/agent/service_worker/llm.test.ts @@ -362,6 +362,24 @@ describe("callLLMWithToolLoop 工具调用循环", () => { return makeSSEResponse(chunks); } + // 与 makeToolCallResponse 相同,但 prompt_tokens 可自定义,用于驱动 usageRatio 跨越裁剪阈值 + function makeToolCallResponseWithUsage( + toolCalls: Array<{ id: string; name: string; arguments: string }>, + promptTokens: number + ): Response { + const chunks: string[] = []; + for (const tc of toolCalls) { + chunks.push( + `data: {"choices":[{"delta":{"tool_calls":[{"id":"${tc.id}","function":{"name":"${tc.name}","arguments":""}}]}}]}\n\n` + ); + chunks.push( + `data: {"choices":[{"delta":{"tool_calls":[{"function":{"arguments":${JSON.stringify(tc.arguments)}}}]}}]}\n\n` + ); + } + chunks.push(`data: {"usage":{"prompt_tokens":${promptTokens},"completion_tokens":5}}\n\n`); + return makeSSEResponse(chunks); + } + function createMockSender() { const sentMessages: any[] = []; const mockConn = { @@ -501,6 +519,65 @@ describe("callLLMWithToolLoop 工具调用循环", () => { registry.unregisterBuiltin("loop"); }); + it("上下文占用跨过裁剪阈值时应分批裁剪窗口外的旧 tool 结果,窗口内轮次保持原文", async () => { + const { service, mockRepo, mockModelRepo } = createTestService(); + const { sender } = createMockSender(); + + // 使用较小的 contextWindow,便于用少量 prompt_tokens 触发裁剪阈值 + mockModelRepo.getModel.mockResolvedValue({ + id: "test-openai", + name: "Test", + provider: "openai", + apiBaseUrl: "", + apiKey: "", + model: "gpt-4o", + contextWindow: 1000, + }); + + const registry = (service as any).toolRegistry; + let callCount = 0; + registry.registerBuiltin( + { name: "counter", description: "Count", parameters: { type: "object", properties: {} } }, + { + execute: async () => { + callCount++; + return `count=${callCount}`; + }, + } + ); + + mockRepo.listConversations.mockResolvedValue([BASE_CONV]); + mockRepo.getMessages.mockResolvedValue([]); + + // 前 4 轮占用较低;第 5 轮跨过 0.4 阈值(此时恰好 5 轮,保留窗口内不裁剪); + // 第 6 轮跨过 0.6 阈值,触发第二次裁剪,第 1 轮此时已超出保留窗口 + const usages = [100, 100, 100, 100, 500, 700]; + for (let i = 0; i < usages.length; i++) { + fetchSpy.mockResolvedValueOnce( + makeToolCallResponseWithUsage([{ id: `c${i + 1}`, name: "counter", arguments: "{}" }], 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("counter"); + }); + it("工具执行后附件回写:toolCalls 被更新", async () => { const { service, mockRepo } = createTestService(); const { sender, sentMessages } = createMockSender(); 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 7f4ecdcb5..5da9899d1 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator.ts @@ -14,8 +14,15 @@ import type { 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 } from "@App/app/service/agent/core/context_elision"; import type { LLMCallResult } from "./llm_client"; +// 上下文占用达到这些比例时,分批裁剪保留窗口外的旧 tool 结果(早于 autoCompact 的 80% 阈值)。 +// 按阈值分批触发而非逐轮触发,避免每轮都重写消息前缀导致 Anthropic 的 prompt cache 断点失效。 +const ELISION_THRESHOLDS = [0.4, 0.6]; +// 保留最近几轮 assistant(带 toolCalls) 及其 tool 结果的完整原文,更早的轮次被裁剪为占位文本 +const ELISION_KEEP_LAST_ASSISTANT_TURNS = 5; + /** ToolLoopOrchestrator 所需的外部依赖(由 AgentService 注入) */ export interface ToolLoopDeps { // callLLM 通过 lambda 注入,确保测试 spy 可以拦截 @@ -81,6 +88,8 @@ export class ToolLoopOrchestrator { const totalUsage = { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 }; const toolCallHistory: ToolCallRecord[] = []; let guardStartIndex = 0; + // 已触发过的裁剪阈值下标(-1 表示尚未触发过),避免同一阈值区间内每轮重复裁剪 + let lastElisionThresholdIndex = -1; while (iterations < maxIterations) { iterations++; @@ -110,13 +119,21 @@ export class ToolLoopOrchestrator { totalUsage.cacheReadInputTokens += result.usage.cacheReadInputTokens || 0; } - // 自动 compact:当上下文占用超过 80% 时触发 + // 自动 compact:当上下文占用超过 80% 时触发;否则按阈值分批裁剪旧 tool 结果(见下方 pendingElision) + let pendingElision = false; 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); + } else { + for (let i = 0; i < ELISION_THRESHOLDS.length; i++) { + if (i > lastElisionThresholdIndex && usageRatio >= ELISION_THRESHOLDS[i]) { + pendingElision = true; + lastElisionThresholdIndex = i; + } + } } } @@ -241,6 +258,11 @@ export class ToolLoopOrchestrator { sendEvent({ type: "system_warning", message: toolCallWarning }); } + // 分批裁剪:待本轮 assistant/tool 消息入列后再裁剪,让本轮内容计入"最近 K 轮"窗口 + if (pendingElision) { + elideOldToolResults(messages, ELISION_KEEP_LAST_ASSISTANT_TURNS); + } + // 通知 UI 即将开始新一轮 LLM 调用,创建新的 assistant 消息 sendEvent({ type: "new_message" }); From d85ba72f75b671007c65fa6bce1711572fd400fd Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 7 Jul 2026 02:46:37 +0900 Subject: [PATCH 04/38] =?UTF-8?q?=E2=9C=A8=20=E5=BE=AA=E7=8E=AF=E6=A3=80?= =?UTF-8?q?=E6=B5=8B=E8=BF=9E=E7=BB=AD=E5=91=BD=E4=B8=AD=E6=97=B6=E6=9A=82?= =?UTF-8?q?=E5=81=9C=E5=B9=B6=E8=AF=A2=E9=97=AE=E7=94=A8=E6=88=B7=E6=98=AF?= =?UTF-8?q?=E5=90=A6=E7=BB=A7=E7=BB=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 针对 #1545 的 Loop Guard 诉求:现有 tool_call_guard 检测到重复/死循环 模式时只向 LLM 注入提醒,LLM 不理会则持续烧 Token,用户无法介入。 - ToolLoopOrchestrator 新增 askUserForGuard 可选回调:循环检测连续命中 达到 2 次时暂停循环,询问用户"继续"或"停止";回答停止则以 done(非 error)优雅收尾并持久化提示 - 仅 UI 对话(含后台会话,复用既有 ask_user 事件/resolver 机制,5 分钟 无人应答默认继续)传入该回调;定时任务与子代理不传,保持原有的 仅告警不暂停行为,避免无人值守场景被阻塞 Co-Authored-By: Claude Sonnet 5 --- .../agent/service_worker/chat_service.ts | 22 +++ .../service/agent/service_worker/llm.test.ts | 26 ++-- .../tool_loop_orchestrator.test.ts | 138 ++++++++++++++++++ .../service_worker/tool_loop_orchestrator.ts | 34 +++++ 4 files changed, 211 insertions(+), 9 deletions(-) create mode 100644 src/app/service/agent/service_worker/tool_loop_orchestrator.test.ts diff --git a/src/app/service/agent/service_worker/chat_service.ts b/src/app/service/agent/service_worker/chat_service.ts index bef80cea5..17d3d52d1 100644 --- a/src/app/service/agent/service_worker/chat_service.ts +++ b/src/app/service/agent/service_worker/chat_service.ts @@ -195,6 +195,27 @@ export class ChatService { } }; + // 循环检测(tool_call_guard)连续命中时暂停询问用户是否继续;复用 ask_user 的事件/resolver 机制, + // 5 分钟无人应答时默认"继续",避免无 UI 监听的后台会话被无限期挂起 + const askUserForGuard = (question: string, options: string[]): Promise => { + return new Promise((resolve) => { + const askId = `guard_${uuidv4()}`; + sendEvent({ type: "ask_user", id: askId, question, options, multiple: false }); + const timer = setTimeout( + () => { + askResolvers.delete(askId); + resolve("Continue"); + }, + 5 * 60 * 1000 + ); + askResolvers.set(askId, (answer: string) => { + clearTimeout(timer); + askResolvers.delete(askId); + resolve(answer); + }); + }); + }; + if (rc) { // 后台模式:初始 listener const listener: ListenerEntry = { @@ -326,6 +347,7 @@ export class ChatService { scriptToolCallback: enableTools && params.tools && params.tools.length > 0 ? scriptToolCallback : null, conversationId: params.conversationId, skipBuiltinTools: !enableTools, + askUserForGuard, }); // 后台模式:正常完成后延迟清理 this.bgSessionManager.cleanupIfDone(params.conversationId); diff --git a/src/app/service/agent/service_worker/llm.test.ts b/src/app/service/agent/service_worker/llm.test.ts index c092c2350..417bc5e24 100644 --- a/src/app/service/agent/service_worker/llm.test.ts +++ b/src/app/service/agent/service_worker/llm.test.ts @@ -534,16 +534,21 @@ describe("callLLMWithToolLoop 工具调用循环", () => { contextWindow: 1000, }); + // 使用两个不同名称的工具交替调用,避免触发 tool_call_guard 的重复调用检测 + // (相同工具名连续出现会命中循环检测,暂停询问用户,与本测试无关) const registry = (service as any).toolRegistry; let callCount = 0; + const execute = async () => { + callCount++; + return `count=${callCount}`; + }; registry.registerBuiltin( - { name: "counter", description: "Count", parameters: { type: "object", properties: {} } }, - { - execute: async () => { - callCount++; - return `count=${callCount}`; - }, - } + { 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]); @@ -553,8 +558,10 @@ describe("callLLMWithToolLoop 工具调用循环", () => { // 第 6 轮跨过 0.6 阈值,触发第二次裁剪,第 1 轮此时已超出保留窗口 const usages = [100, 100, 100, 100, 500, 700]; 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: "counter", arguments: "{}" }], usages[i]) + makeToolCallResponseWithUsage([{ id: `c${i + 1}`, name: toolName, arguments: `{"round":${i}}` }], usages[i]) ); } // 第 7 轮:最终文本,结束循环 @@ -575,7 +582,8 @@ describe("callLLMWithToolLoop 工具调用循环", () => { expect(toolMessages[1].content).toBe("count=2"); expect(toolMessages[toolMessages.length - 1].content).toBe("count=6"); - registry.unregisterBuiltin("counter"); + registry.unregisterBuiltin("counterA"); + registry.unregisterBuiltin("counterB"); }); it("工具执行后附件回写:toolCalls 被更新", async () => { 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..8adb9253a --- /dev/null +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator.test.ts @@ -0,0 +1,138 @@ +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 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), + } 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", + ...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("第 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); + const [question, options] = askUserForGuard.mock.calls[0]; + expect(question).toContain("2"); + expect(options).toContain("Stop"); + + 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" && m.content.includes("Stop")); + expect(stopMessage).toBeDefined(); + }); +}); 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 5da9899d1..d117e7cf5 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator.ts @@ -23,6 +23,10 @@ const ELISION_THRESHOLDS = [0.4, 0.6]; // 保留最近几轮 assistant(带 toolCalls) 及其 tool 结果的完整原文,更早的轮次被裁剪为占位文本 const ELISION_KEEP_LAST_ASSISTANT_TURNS = 5; +// 循环检测(tool_call_guard)连续命中达到此次数时,暂停并询问用户是否继续(仅当调用方提供 askUserForGuard 时生效) +const GUARD_ESCALATION_STRIKES = 2; +const GUARD_STOP_ANSWER = "Stop"; + /** ToolLoopOrchestrator 所需的外部依赖(由 AgentService 注入) */ export interface ToolLoopDeps { // callLLM 通过 lambda 注入,确保测试 spy 可以拦截 @@ -70,6 +74,9 @@ export class ToolLoopOrchestrator { excludeTools?: string[]; // 是否启用 prompt caching,默认 true cache?: boolean; + // 循环检测连续命中达到 GUARD_ESCALATION_STRIKES 次时调用,暂停循环询问用户是否继续。 + // 仅由 UI 对话(含后台会话)传入;定时任务、子代理不传,保持原有的仅告警不暂停行为。 + askUserForGuard?: (question: string, options: string[]) => Promise; }): Promise { const { toolRegistry, @@ -90,6 +97,8 @@ export class ToolLoopOrchestrator { let guardStartIndex = 0; // 已触发过的裁剪阈值下标(-1 表示尚未触发过),避免同一阈值区间内每轮重复裁剪 let lastElisionThresholdIndex = -1; + // 循环检测命中次数,达到 GUARD_ESCALATION_STRIKES 后暂停询问用户 + let guardStrikeCount = 0; while (iterations < maxIterations) { iterations++; @@ -256,6 +265,31 @@ export class ToolLoopOrchestrator { 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 params.askUserForGuard( + `[System] The Agent has triggered the loop-guard warning ${guardStrikeCount} times in this run. Continue letting it proceed automatically, or stop here?`, + ["Continue", GUARD_STOP_ANSWER] + ); + if (answer.trim().toLowerCase() === GUARD_STOP_ANSWER.toLowerCase()) { + const durationMs = Date.now() - startTime; + if (conversationId) { + await this.chatRepo.appendMessage({ + id: uuidv4(), + conversationId, + role: "assistant", + content: "Stopped at the user's request after repeated loop-guard warnings.", + usage: totalUsage, + durationMs, + createtime: Date.now(), + }); + } + sendEvent({ type: "done", usage: totalUsage, durationMs }); + return; + } + } } // 分批裁剪:待本轮 assistant/tool 消息入列后再裁剪,让本轮内容计入"最近 K 轮"窗口 From dd1d162996427db3c63f1c0201472f2335c14707 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 7 Jul 2026 02:52:05 +0900 Subject: [PATCH 05/38] Delete issue-1545-proposal.md --- issue-1545-proposal.md | 130 ----------------------------------------- 1 file changed, 130 deletions(-) delete mode 100644 issue-1545-proposal.md diff --git a/issue-1545-proposal.md b/issue-1545-proposal.md deleted file mode 100644 index 85b99a291..000000000 --- a/issue-1545-proposal.md +++ /dev/null @@ -1,130 +0,0 @@ -# Technical Proposal — Issue #1545: Agent maxIterations Configuration, Context Compression & Loop Guard - -> Issue: https://github.com/scriptscat/scriptcat/issues/1545 -> Branch analyzed: `feat/ai-001` -> Status: proposal (no code changes yet) - -The issue raises three pain points when using the built-in AI Agent for heavy tool-calling workloads (Webpack reverse-engineering, repeated `execute_script` probing): - -1. The 50-iteration hard limit cannot be raised from the UI. -2. Token consumption is very high because full tool outputs are retained across dozens of rounds. -3. No loop guard pauses the agent when it repeats near-identical failing calls. - -This document maps each ask against what already exists in the codebase, identifies the real gaps, and proposes a phased implementation. - ---- - -## 1. Current implementation — findings - -### 1.1 Iteration limits are real, hardcoded per entry point, and not exposed for UI chat - -The unified loop lives in `ToolLoopOrchestrator.callLLMWithToolLoop()` — `src/app/service/agent/service_worker/tool_loop_orchestrator.ts:85` (`while (iterations < maxIterations)`). On exhaustion it persists an error message and emits `errorCode: "max_iterations"` (`tool_loop_orchestrator.ts:271-287`), which is exactly the error quoted in the issue. - -Each caller supplies its own default: - -| Entry point | Default | User-configurable today? | Source | -|---|---|---|---| -| UI chat (options panel) | **50** | **No** — UI never sends the param | `chat_service.ts:319` (`params.maxIterations \|\| 50`); the chat payload built in `src/pages/options/routes/Agent/Chat/hooks.ts:192-199` has no `maxIterations` field | -| Userscript `CAT.agent.conversation` (ephemeral) | 20 | Yes, via `ConversationCreateOptions.maxIterations` | `chat_service.ts:398`, `src/app/service/agent/core/types.ts:219-230` | -| Scheduled internal tasks | 10 | **Yes — already has a UI field** | `task_service.ts:170`, `src/pages/options/routes/Agent/Tasks/TaskFormDialog.tsx:176-184` | -| Sub-agents (researcher / page_operator / general) | 20 / 30 / 30 | No | `src/app/service/agent/core/sub_agent_types.ts:33,68,89` | - -So the issue's request ("expose `maxIterations` in settings, inject into `ConversationCreateOptions`") is: (a) already satisfied for userscripts and scheduled tasks, (b) genuinely missing **only for the UI chat panel** — the highest-traffic path. - -**Important correction to the issue's "Alternatives" section:** hitting the limit does *not* lose the conversation. All messages (including every tool round) are persisted via `chatRepo.appendMessage` during the loop, so sending another message (e.g. "继续") resumes with a fresh 50-iteration budget and full history. The perceived "must start a new conversation and lose everything" is a UX/communication gap, not a data loss — nothing in the UI tells the user this. This makes a "Continue" affordance on the `max_iterations` error extremely cheap and high-value. - -### 1.2 Context compression exists, but is all-or-nothing, and the biggest token leak is elsewhere - -What exists: - -- **Auto-compact**: when `inputTokens / contextWindow ≥ 0.8`, the whole history is summarized by the LLM and replaced with a single `[Conversation Summary]` message (`tool_loop_orchestrator.ts:113-121`, `compact_service.ts:50-96`, window sizes from `core/model_context.ts`). It fires only at 80% — for a 200K-window model that is ~160K tokens *per subsequent request*, long after the cost pain the issue describes. -- **Per-tool truncation is inconsistent**: `get_tab_content` truncates at `maxLength` and can route through the summary model (`core/tools/tab_tools.ts:169-186`), but **`execute_script` returns `JSON.stringify(result)` unbounded** (`core/tools/execute_script.ts:61,70`). A script that returns a DOM dump or module map injects megabytes into the context, and it is then re-sent on *every* remaining iteration. This is precisely the issue's scenario. -- **Tool results are never pruned**: every round appends full results (`tool_loop_orchestrator.ts:166`) and the array only shrinks at the 80% compact cliff. - -The largest single cost factor, however, is a **prompt-caching gap**: the Anthropic provider sets `cache_control` only on the system prompt and tool definitions (`core/providers/anthropic.ts:139-141,153-155`) — **never on conversation messages**. In a 50-iteration loop the ever-growing message history is billed at the full input rate on every round, roughly O(n²) tokens per task. A moving cache breakpoint on the last message would convert the shared prefix of each round into cache reads (~10% of the input price on Anthropic). This change alone likely delivers more savings than any summarization scheme, with zero behavior change. - -### 1.3 A loop guard already exists — but only nudges the model, never the user - -`src/app/service/agent/core/tool_call_guard.ts` runs every round (`tool_loop_orchestrator.ts:237-242`) with four heuristics: identical tool+args called twice, `execute_script` returning null 3× consecutively, `get_tab_content` on the same tab 3×, and any tool ≥5 times in the last 8 calls. On detection it injects a `[System Warning]` user message and emits a `system_warning` UI event (rendered at `ChatArea.tsx:377`). - -Gap vs. the issue's ask: warnings only steer the LLM; if the model ignores them, tokens keep burning. There is no pause-and-confirm. The building blocks for one already exist: the `ask_user` tool (`core/tools/ask_user.ts`), the `askResolvers` plumbing in `chat_service.ts`, and the pending-question UI in `hooks.ts:169-174`. - ---- - -## 2. Proposal - -### Phase 1 — Configurable max iterations + "Continue" affordance (core ask, small) - -**2.1 `AgentConfigRepo`.** Add a small repo following the existing `SearchConfigRepo` pattern (`core/tools/search_config.ts`, `chrome.storage.local`, key `agent_config`): - -```ts -export type AgentGeneralConfig = { - chatMaxIterations: number; // default 50, clamp 1–1000 -}; -``` - -Expose `getAgentConfig` / `saveAgentConfig` via `Group.on(...)` in `agent.ts` next to the existing `getSearchConfig`/`saveSearchConfig` handlers (`agent.ts:215-216`), and mirror them on `agentClient`. - -**2.2 Wire into the chat path.** In `chat_service.ts:319`, resolve the limit as `params.maxIterations ?? config.chatMaxIterations ?? 50`. Explicit per-call values (userscripts, tasks, sub-agents) keep precedence; their defaults stay 20/10/20-30 — this setting governs UI chat only. (Optionally a later iteration can apply the same config as the *default* for `ConversationCreateOptions` when a script omits it, as the issue suggests, but that changes script-facing defaults and should be a separate, documented decision.) - -**2.3 Settings UI.** New "Conversation" (对话) card on the Agent Settings page (`src/pages/options/routes/Agent/Settings/index.tsx`), one numeric `SettingsField` mirroring the task dialog's `task-max-iter` input. The description carries the cost warning the issue itself proposes ("调大此选项可能会增加单次任务的 Token 开销"). i18n keys go into `src/locales//agent.json` for all 8 locales, following `docs/translation/README.md` — no `defaultValue` fallbacks (banned by `scriptcat/no-i18n-default-value`). - -**2.4 "Continue" button on the `max_iterations` error.** In the chat error rendering (`MessageItem.tsx:73-76` / `ChatArea.tsx`), when the persisted message's error code is `max_iterations`, show a one-click "继续 / Continue" action that re-invokes the existing `sendMessage` with a fixed continue prompt. Because history is already persisted (see §1.1), this fully solves the "lost 30 rounds of reverse-engineering context" complaint without any unlimited mode. - -**On "unlimited" mode:** recommend **not** shipping a literal unlimited option. A bounded cap (up to 1000) plus the Continue button plus Phase 3 checkpoints gives the same capability with a safety floor. Unbounded loops in a background session (`background_session_manager.ts`) with a stuck model have no natural stop. - -### Phase 2 — Token diet (highest ROI first) - -**2.5 Message-history prompt caching (do this first).** In `core/providers/anthropic.ts`, add a `cache_control: { type: "ephemeral" }` breakpoint to the content of the last message (and optionally the second-to-last, staying within Anthropic's 4-breakpoint limit alongside system + tools). Each loop iteration then pays cache-read for the entire shared prefix instead of full input price. No behavior change, no UI, immediately addresses "单次逆向流程调试花费近 3 元". OpenAI-compatible providers already benefit from automatic prefix caching; the stable message ordering required is already in place. - -**2.6 Cap `execute_script` output at the source.** In `core/tools/execute_script.ts`, truncate the stringified result beyond a threshold (suggest 30 000 chars ≈ 8K tokens) with head + tail retention and an explicit marker (`…[truncated 412 kB — return a smaller value or write to OPFS via opfs_write]`), mirroring the `get_tab_content` precedent (`tab_tools.ts:169-186`). This is the single biggest defense for the issue's exact workload. Apply the same generic cap in `ToolLoopOrchestrator` before `messages.push({ role: "tool", ... })` as a backstop for MCP/skill tools. The *persisted* message keeps the (already produced) full text so the UI can still show it; only the LLM context is capped. - -**2.7 Sliding-window elision of old tool results.** When building each round's request, replace `role: "tool"` contents older than the last K assistant turns (suggest K = 5) with a stub (`[tool result elided — re-run the tool if needed]`), keeping assistant text and tool-call names intact so the reasoning trail survives. Two constraints learned from the current code: - -- **Prune the in-flight `messages` array only** — never the repo. Persistence and UI history stay complete (same separation `autoCompact` already uses). -- **Batch the elision at thresholds** (e.g. at 40% / 60% context usage), not every round — every prefix rewrite invalidates the Phase 2.5 prompt cache, so continuous pruning would cost more than it saves. Threshold-stepped pruning keeps long stable prefixes between rewrites. - -Additionally, lower the compact trigger from 80% to ~70% and consider keeping the last K rounds verbatim after compact (today `autoCompact` replaces *everything* with the summary, `compact_service.ts:78-82`, dropping fresh tool state the model still needs). - -### Phase 3 — Loop-guard escalation to the user (the issue's point 3) - -**2.8 Escalating guard.** Extend `detectToolCallIssues` (or wrap it in the orchestrator) with a strike counter per conversation. Today each warning advances `guardStartIndex` and the loop continues unconditionally (`tool_loop_orchestrator.ts:237-242`). Proposed: after the 2nd guard hit in one run, pause the loop and ask the user via the existing `ask_user` machinery ("Agent 疑似陷入循环(已重复调用 execute_script N 次且结果相似),是否继续?[继续 / 停止]"). On "stop", end the run gracefully (persist partial result) instead of erroring. - -**2.9 Iteration checkpoints.** Optional setting `iterationCheckpoint` (e.g. every 25 rounds): the loop pauses and asks "已连续执行 25 次工具调用,是否继续?" — matching the competitor behavior cited in the issue. Rules: - -- **UI chat only.** Scheduled tasks (`task_service.ts`) and sub-agents must never block on a human; they keep hard caps. -- **Background sessions** (UI detached, `background_session_manager.ts`): auto-continue with a logged `system_warning`, or auto-stop per setting — an unanswerable question must not hang the service worker. - -### Out of scope (rejected alternatives) - -- **Similarity-based ML loop detection**: the existing four heuristics plus escalation cover the practical cases; embedding-based similarity adds latency and a model dependency for marginal gain. -- **Client-side token pre-counting** for exact budget UI: provider tokenizers differ; the existing `usage`-driven ratio is sufficient. - ---- - -## 3. Compatibility & security - -- All changes are additive. Existing defaults (50 / 20 / 10 / 20-30) are unchanged; the userscript API surface (`ConversationCreateOptions`) is untouched in Phase 1. -- Upper clamp (1000) plus checkpoint confirmations bound worst-case spend; the settings field carries the token-cost warning text the issue requested. -- Truncation caps reduce, never increase, what leaves the browser toward LLM providers. -- Cache breakpoints send no additional data; `cache: false` callers (e.g. compact runs, `compact_service.ts:71`) are unaffected. - -## 4. Test plan (TDD, per `AGENTS.md`) - -Failing tests first, BDD-style Chinese titles, extending existing suites: - -1. `tool_loop_orchestrator` (`agent.test.ts` / new spec): reads config default when caller omits `maxIterations`; emits checkpoint `ask_user` at N; guard escalation pauses after 2nd strike; elision keeps last K tool results and repo messages intact. -2. `execute_script` truncation: oversized result capped with marker; small results untouched. -3. `anthropic` provider (`llm.test.ts` pattern): `cache_control` present on last message block when `cache !== false`, absent otherwise, ≤4 breakpoints total. -4. Settings UI (`Settings/index.test.tsx` pattern): field renders, clamps 1–1000, persists via `agentClient`. -5. Chat UI: `max_iterations` error renders Continue action; clicking resumes the conversation. - -## 5. Suggested rollout - -| Phase | Items | Size | User-visible effect | -|---|---|---|---| -| 1 | Config repo + settings field + wire-in + Continue button | S | Unblocks the issue author immediately | -| 2 | Cache breakpoints → execute_script cap → sliding window | M | 5–10× cost reduction on long tool loops | -| 3 | Guard escalation + iteration checkpoints | M | Stops dead loops before the budget burns | - -Phases are independently shippable; Phase 2.5 (cache breakpoints) is the best effort-to-savings ratio in the whole proposal and could even ship first. From de83fd38d5c5fe07fb059a7be035f7897e237922 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 8 Jul 2026 07:14:01 +0900 Subject: [PATCH 06/38] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=AE=A1=E6=9F=A5=E5=8F=91=E7=8E=B0=E7=9A=84?= =?UTF-8?q?=204=20=E5=A4=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 针对 PR #1549 的审查反馈: 1. 【阻断性】继续对话时,历史中 max_iterations 错误占位消息(content 为空字符串)此前会被完整重放进 LLM 请求,部分 provider(如 Anthropic)会因空 content 拒绝请求。buildAndPersistUserMessage 现在 跳过带 error 字段的历史消息,仅用于 UI 展示,不进入 LLM 上下文。 2. 循环检测升级提问 5 分钟无人应答超时后,此前只删除 resolver,未清除 后台会话的 rc.pendingAskUser,导致后续 attach 的 UI 仍会看到已失效 的提问,回答会静默无效。超时回调现在同步清除 pendingAskUser。 3. chatMaxIterations 的合法范围此前只在 Settings UI 校验, AgentConfigRepo 直接读写未归一化的原始值;损坏的 storage、旧版本 遗留值或绕过 UI 的写入都可能导致 0/负数/超大值,进而导致循环立即 报错或失控运行。新增 normalizeChatMaxIterations,在 getConfig / saveConfig 中统一归一化到 [1, 1000];ChatService 解析最终值时改用 ?? 并对结果做同样的兜底截断。 4. 循环检测升级的连续命中计数此前永不重置,用户选择"继续"后,此后 每一次告警都会重新暂停询问,比"连续命中 2 次暂停"更激进。现在回答 继续后重置计数,需再次连续命中 2 次才会重新暂停。 均已按 TDD 补充回归测试(先复现失败用例,再修复)。 Co-Authored-By: Claude Sonnet 5 --- .../service/agent/core/agent_config.test.ts | 40 ++++++++++++ src/app/service/agent/core/agent_config.ts | 15 ++++- .../agent/service_worker/background.test.ts | 62 ++++++++++++++++++- .../agent/service_worker/chat_service.ts | 13 +++- .../service/agent/service_worker/llm.test.ts | 58 +++++++++++++++++ .../tool_loop_orchestrator.test.ts | 24 +++++++ .../service_worker/tool_loop_orchestrator.ts | 4 +- 7 files changed, 210 insertions(+), 6 deletions(-) diff --git a/src/app/service/agent/core/agent_config.test.ts b/src/app/service/agent/core/agent_config.test.ts index 9d3be6604..1fa76cd68 100644 --- a/src/app/service/agent/core/agent_config.test.ts +++ b/src/app/service/agent/core/agent_config.test.ts @@ -39,5 +39,45 @@ describe("AgentConfigRepo", () => { 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 index 98d8cf70c..954a63087 100644 --- a/src/app/service/agent/core/agent_config.ts +++ b/src/app/service/agent/core/agent_config.ts @@ -7,23 +7,34 @@ export type AgentGeneralConfig = { 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 { ...DEFAULT_CONFIG, ...stored }; + return { chatMaxIterations: normalizeChatMaxIterations(stored?.chatMaxIterations) }; } catch { return DEFAULT_CONFIG; } } async saveConfig(config: AgentGeneralConfig): Promise { - await chrome.storage.local.set({ [STORAGE_KEY]: config }); + const normalized: AgentGeneralConfig = { chatMaxIterations: normalizeChatMaxIterations(config.chatMaxIterations) }; + await chrome.storage.local.set({ [STORAGE_KEY]: normalized }); } } diff --git a/src/app/service/agent/service_worker/background.test.ts b/src/app/service/agent/service_worker/background.test.ts index 82db11843..b5a708c1f 100644 --- a/src/app/service/agent/service_worker/background.test.ts +++ b/src/app/service/agent/service_worker/background.test.ts @@ -1,5 +1,20 @@ 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[] = []; + for (const tc of toolCalls) { + chunks.push( + `data: {"choices":[{"delta":{"tool_calls":[{"id":"${tc.id}","function":{"name":"${tc.name}","arguments":""}}]}}]}\n\n` + ); + chunks.push( + `data: {"choices":[{"delta":{"tool_calls":[{"function":{"arguments":${JSON.stringify(tc.arguments)}}}]}}]}\n\n` + ); + } + chunks.push(`data: {"usage":{"prompt_tokens":10,"completion_tokens":5}}\n\n`); + return makeSSEResponse(chunks); +} // ---- updateStreamingState 快照状态管理 ---- @@ -624,6 +639,51 @@ describe("后台运行会话 集成测试", () => { // 不应抛异常 }); + it("循环检测升级提问超时(5 分钟无人应答)后,应清除 rc.pendingAskUser,避免后续 attach 看到过期提问", async () => { + vi.useFakeTimers(); + try { + const { service, mockRepo } = createTestService(); + setupConversation(mockRepo); + const { sender } = 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(); + + registry.unregisterBuiltin("dup"); + } finally { + vi.useRealTimers(); + } + }); + it("getRunningConversationIds 返回正确的 ID 列表", () => { const { service } = createTestService(); diff --git a/src/app/service/agent/service_worker/chat_service.ts b/src/app/service/agent/service_worker/chat_service.ts index 17d3d52d1..45e0b17d7 100644 --- a/src/app/service/agent/service_worker/chat_service.ts +++ b/src/app/service/agent/service_worker/chat_service.ts @@ -13,6 +13,7 @@ import type { ScriptToolCallback, ToolExecutor } from "@App/app/service/agent/co 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"; @@ -204,6 +205,8 @@ export class ChatService { const timer = setTimeout( () => { askResolvers.delete(askId); + // 后台会话:清除过期的 pendingAskUser,避免后续 attach 的 UI 看到已超时的提问 + if (rc && rc.pendingAskUser?.id === askId) rc.pendingAskUser = undefined; resolve("Continue"); }, 5 * 60 * 1000 @@ -341,7 +344,10 @@ export class ChatService { model, messages, tools: enableTools ? params.tools : undefined, - maxIterations: params.maxIterations || agentConfig.chatMaxIterations, + // ?? 而非 || :显式传入 0 不应被当作"未传入"而回退到配置值; + // 无论来自配置还是直接传参,最终值都经 normalizeChatMaxIterations 兜底截断, + // 避免非法值(如负数)导致循环立即失败或无限运行 + maxIterations: normalizeChatMaxIterations(params.maxIterations ?? agentConfig.chatMaxIterations), sendEvent, signal: abortController.signal, scriptToolCallback: enableTools && params.tools && params.tools.length > 0 ? scriptToolCallback : null, @@ -671,9 +677,12 @@ export class ChatService { }); messages.push({ role: "system", content: systemContent }); - // 添加历史消息(跳过 system) + // 添加历史消息(跳过 system;跳过错误占位消息 —— 如超过 max_iterations 时持久化的空 content + // assistant 消息,仅用于 UI 展示"继续对话"操作,重放给 LLM 会产生空 content 的无意义消息, + // 部分 provider(如 Anthropic)甚至会因空 content 拒绝请求) for (const msg of existingMessages) { if (msg.role === "system") continue; + if (msg.error) continue; messages.push({ role: msg.role, content: msg.content, diff --git a/src/app/service/agent/service_worker/llm.test.ts b/src/app/service/agent/service_worker/llm.test.ts index 417bc5e24..051991570 100644 --- a/src/app/service/agent/service_worker/llm.test.ts +++ b/src/app/service/agent/service_worker/llm.test.ts @@ -519,6 +519,64 @@ 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(); 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 index 8adb9253a..efa9e02ca 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator.test.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator.test.ts @@ -135,4 +135,28 @@ describe("ToolLoopOrchestrator 循环检测升级(loop-guard escalation)", ( const stopMessage = assistantCalls.find((m: any) => typeof m.content === "string" && m.content.includes("Stop")); 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]).toContain("2"); + expect(askUserForGuard.mock.calls[1][0]).toContain("2"); + + expect(callLLM).toHaveBeenCalledTimes(9); + const doneEvents = sendEvent.mock.calls.filter((c) => c[0].type === "done"); + expect(doneEvents).toHaveLength(1); + }); }); 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 d117e7cf5..b87846eb8 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator.ts @@ -270,7 +270,7 @@ export class ToolLoopOrchestrator { // 连续命中达到阈值时暂停,询问用户是否继续(仅 UI 对话传入 askUserForGuard 时生效) if (guardStrikeCount >= GUARD_ESCALATION_STRIKES && params.askUserForGuard) { const answer = await params.askUserForGuard( - `[System] The Agent has triggered the loop-guard warning ${guardStrikeCount} times in this run. Continue letting it proceed automatically, or stop here?`, + `[System] The Agent has triggered the loop-guard warning ${guardStrikeCount} times since the last check. Continue letting it proceed automatically, or stop here?`, ["Continue", GUARD_STOP_ANSWER] ); if (answer.trim().toLowerCase() === GUARD_STOP_ANSWER.toLowerCase()) { @@ -289,6 +289,8 @@ export class ToolLoopOrchestrator { sendEvent({ type: "done", usage: totalUsage, durationMs }); return; } + // 用户选择继续:重置命中计数,避免此后每一次告警都重新弹出询问 + guardStrikeCount = 0; } } From fddde4cb8e2eb78ca8c42b928b52c47f2349a356 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:31:42 +0900 Subject: [PATCH 07/38] fix agent loop context accounting and cancellation --- .../service_worker/tool_loop_orchestrator.ts | 217 +++++++++++++++--- 1 file changed, 183 insertions(+), 34 deletions(-) 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 b87846eb8..9c4c1672a 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator.ts @@ -1,5 +1,8 @@ import type { AgentChatRepo } from "@App/app/repo/agent_chat"; -import type { ScriptToolCallback, ToolExecutorLike } from "@App/app/service/agent/core/tool_registry"; +import type { + ScriptToolCallback, + ToolExecutorLike, +} from "@App/app/service/agent/core/tool_registry"; import type { AgentModelConfig, ChatRequest, @@ -13,12 +16,15 @@ import type { } 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 { + detectToolCallIssues, + type ToolCallRecord, +} from "@App/app/service/agent/core/tool_call_guard"; import { elideOldToolResults } from "@App/app/service/agent/core/context_elision"; import type { LLMCallResult } from "./llm_client"; // 上下文占用达到这些比例时,分批裁剪保留窗口外的旧 tool 结果(早于 autoCompact 的 80% 阈值)。 -// 按阈值分批触发而非逐轮触发,避免每轮都重写消息前缀导致 Anthropic 的 prompt cache 断点失效。 +// 按阈值分批触发,并在 60% 以上每新增一个保留窗口重新裁剪,兼顾真正的滑动窗口与 prompt cache 稳定性。 const ELISION_THRESHOLDS = [0.4, 0.6]; // 保留最近几轮 assistant(带 toolCalls) 及其 tool 结果的完整原文,更早的轮次被裁剪为占位文本 const ELISION_KEEP_LAST_ASSISTANT_TURNS = 5; @@ -27,21 +33,76 @@ const ELISION_KEEP_LAST_ASSISTANT_TURNS = 5; const GUARD_ESCALATION_STRIKES = 2; const GUARD_STOP_ANSWER = "Stop"; +type AskUserForGuard = (question: string, options: string[]) => 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, + question: string, + options: string[], + 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(() => askUserForGuard(question, options)) + .then(resolveOnce, rejectOnce); + }); +} + /** ToolLoopOrchestrator 所需的外部依赖(由 AgentService 注入) */ export interface ToolLoopDeps { // callLLM 通过 lambda 注入,确保测试 spy 可以拦截 callLLM( model: AgentModelConfig, - params: { messages: ChatRequest["messages"]; tools?: ToolDefinition[]; cache?: boolean }, + params: { + messages: ChatRequest["messages"]; + tools?: ToolDefinition[]; + cache?: boolean; + }, sendEvent: (event: ChatStreamEvent) => void, - signal: AbortSignal + signal: AbortSignal, ): Promise; autoCompact( conversationId: string, model: AgentModelConfig, messages: ChatRequest["messages"], sendEvent: (event: ChatStreamEvent) => void, - signal: AbortSignal + signal: AbortSignal, ): Promise; } @@ -51,7 +112,7 @@ export class ToolLoopOrchestrator { // 保证并发会话各自使用独立的工具注册表,避免闭包互相覆盖。 constructor( private deps: ToolLoopDeps, - private chatRepo: AgentChatRepo + private chatRepo: AgentChatRepo, ) {} // 统一的 tool calling 循环,UI 和脚本共用 @@ -76,7 +137,7 @@ export class ToolLoopOrchestrator { cache?: boolean; // 循环检测连续命中达到 GUARD_ESCALATION_STRIKES 次时调用,暂停循环询问用户是否继续。 // 仅由 UI 对话(含后台会话)传入;定时任务、子代理不传,保持原有的仅告警不暂停行为。 - askUserForGuard?: (question: string, options: string[]) => Promise; + askUserForGuard?: AskUserForGuard; }): Promise { const { toolRegistry, @@ -92,11 +153,19 @@ export class ToolLoopOrchestrator { const startTime = Date.now(); let iterations = 0; - const totalUsage = { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 }; + const totalUsage = { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + }; const toolCallHistory: ToolCallRecord[] = []; let guardStartIndex = 0; // 已触发过的裁剪阈值下标(-1 表示尚未触发过),避免同一阈值区间内每轮重复裁剪 let lastElisionThresholdIndex = -1; + // 60% 以上按“每新增一个保留窗口”重新裁剪,避免窗口在第二个阈值后停止滑动 + let assistantToolTurns = 0; + let lastElisionAssistantTurn = 0; // 循环检测命中次数,达到 GUARD_ESCALATION_STRIKES 后暂停询问用户 let guardStrikeCount = 0; @@ -104,7 +173,9 @@ export class ToolLoopOrchestrator { iterations++; // 每轮重新获取工具定义(load_skill 可能动态注册了新工具) - let allToolDefs = params.skipBuiltinTools ? tools || [] : toolRegistry.getDefinitions(tools); + 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)); @@ -113,9 +184,13 @@ export class ToolLoopOrchestrator { // 调用 LLM(重试由 llm_client 内部处理) const result = await this.deps.callLLM( model, - { messages, tools: allToolDefs.length > 0 ? allToolDefs : undefined, cache: params.cache }, + { + messages, + tools: allToolDefs.length > 0 ? allToolDefs : undefined, + cache: params.cache, + }, sendEvent, - signal + signal, ); if (signal.aborted) return; @@ -124,21 +199,34 @@ export class ToolLoopOrchestrator { 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; + totalUsage.cacheCreationInputTokens += + result.usage.cacheCreationInputTokens || 0; + totalUsage.cacheReadInputTokens += + result.usage.cacheReadInputTokens || 0; } // 自动 compact:当上下文占用超过 80% 时触发;否则按阈值分批裁剪旧 tool 结果(见下方 pendingElision) let pendingElision = false; + let contextUsageRatio: number | null = null; if (result.usage && conversationId) { const contextWindow = getContextWindow(model); - const usageRatio = result.usage.inputTokens / contextWindow; + contextUsageRatio = + getContextInputTokens(model, result.usage) / contextWindow; - if (usageRatio >= 0.8) { - await this.deps.autoCompact(conversationId, model, messages, sendEvent, signal); + if (contextUsageRatio >= 0.8) { + await this.deps.autoCompact( + conversationId, + model, + messages, + sendEvent, + signal, + ); } else { for (let i = 0; i < ELISION_THRESHOLDS.length; i++) { - if (i > lastElisionThresholdIndex && usageRatio >= ELISION_THRESHOLDS[i]) { + if ( + i > lastElisionThresholdIndex && + contextUsageRatio >= ELISION_THRESHOLDS[i] + ) { pendingElision = true; lastElisionThresholdIndex = i; } @@ -150,7 +238,8 @@ export class ToolLoopOrchestrator { const buildMessageContent = (): MessageContent => { if (result.contentBlocks && result.contentBlocks.length > 0) { const blocks: ContentBlock[] = []; - if (result.content) blocks.push({ type: "text", text: result.content }); + if (result.content) + blocks.push({ type: "text", text: result.content }); blocks.push(...result.contentBlocks); return blocks; } @@ -158,7 +247,11 @@ export class ToolLoopOrchestrator { }; // 如果有 tool calls,需要执行并继续循环 - if (result.toolCalls && result.toolCalls.length > 0 && allToolDefs.length > 0) { + if ( + result.toolCalls && + result.toolCalls.length > 0 && + allToolDefs.length > 0 + ) { // 持久化 assistant 消息(含 tool calls) if (conversationId) { await this.chatRepo.appendMessage({ @@ -166,20 +259,32 @@ export class ToolLoopOrchestrator { conversationId, role: "assistant", content: buildMessageContent(), - thinking: result.thinking ? { content: result.thinking } : undefined, + 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 }); + 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); + params.excludeTools && params.excludeTools.length > 0 + ? new Set(params.excludeTools) + : undefined; + const toolResults = await toolRegistry.execute( + result.toolCalls, + scriptToolCallback, + excludeToolsSet, + ); // 将 tool 结果加入消息,并通知 UI 工具执行完成 // 收集需要回写的 toolCall 元数据(执行状态 / 附件 / 子代理详情) @@ -189,9 +294,18 @@ export class ToolLoopOrchestrator { for (const tr of toolResults) { // LLM 上下文只包含文本结果,不含附件 - messages.push({ role: "tool", content: tr.result, toolCallId: tr.id }); + 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 }); + sendEvent({ + type: "tool_call_complete", + id: tr.id, + result: tr.result, + attachments: tr.attachments, + }); completedIds.add(tr.id); if (tr.attachments?.length) { @@ -229,7 +343,9 @@ export class ToolLoopOrchestrator { // 内存上下文中的 assistant 消息 const assistantMsg = messages.find( - (m) => m.role === "assistant" && m.toolCalls?.some((tc: ToolCall) => completedIds.has(tc.id)) + (m) => + m.role === "assistant" && + m.toolCalls?.some((tc: ToolCall) => completedIds.has(tc.id)), ); if (assistantMsg?.toolCalls) applyToolUpdates(assistantMsg.toolCalls); @@ -238,7 +354,10 @@ export class ToolLoopOrchestrator { 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))) { + if ( + msg.role === "assistant" && + msg.toolCalls?.some((tc: ToolCall) => completedIds.has(tc.id)) + ) { applyToolUpdates(msg.toolCalls!); await this.chatRepo.saveMessages(conversationId, allMessages); break; @@ -260,7 +379,10 @@ export class ToolLoopOrchestrator { // 工具调用模式检测:检测重复/循环模式并注入针对性提醒 // 每次警告后推进 startIndex,避免旧记录持续触发同一条警告 - const toolCallWarning = detectToolCallIssues(toolCallHistory, guardStartIndex); + const toolCallWarning = detectToolCallIssues( + toolCallHistory, + guardStartIndex, + ); if (toolCallWarning) { guardStartIndex = toolCallHistory.length; messages.push({ role: "user", content: toolCallWarning }); @@ -268,19 +390,32 @@ export class ToolLoopOrchestrator { guardStrikeCount++; // 连续命中达到阈值时暂停,询问用户是否继续(仅 UI 对话传入 askUserForGuard 时生效) - if (guardStrikeCount >= GUARD_ESCALATION_STRIKES && params.askUserForGuard) { - const answer = await params.askUserForGuard( + if ( + guardStrikeCount >= GUARD_ESCALATION_STRIKES && + params.askUserForGuard + ) { + const answer = await waitForGuardAnswer( + params.askUserForGuard, `[System] The Agent has triggered the loop-guard warning ${guardStrikeCount} times since the last check. Continue letting it proceed automatically, or stop here?`, - ["Continue", GUARD_STOP_ANSWER] + ["Continue", GUARD_STOP_ANSWER], + signal, ); - if (answer.trim().toLowerCase() === GUARD_STOP_ANSWER.toLowerCase()) { + if (answer === null) { + const durationMs = Date.now() - startTime; + sendEvent({ type: "done", usage: totalUsage, durationMs }); + return; + } + if ( + answer.trim().toLowerCase() === GUARD_STOP_ANSWER.toLowerCase() + ) { const durationMs = Date.now() - startTime; if (conversationId) { await this.chatRepo.appendMessage({ id: uuidv4(), conversationId, role: "assistant", - content: "Stopped at the user's request after repeated loop-guard warnings.", + content: + "Stopped at the user's request after repeated loop-guard warnings.", usage: totalUsage, durationMs, createtime: Date.now(), @@ -294,9 +429,23 @@ export class ToolLoopOrchestrator { } } + 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 消息 From 19024ecb418f244d7285ff47410af247d23e6a4e Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:34:30 +0900 Subject: [PATCH 08/38] Update tool_loop_orchestrator.ts --- .../service_worker/tool_loop_orchestrator.ts | 114 +++++------------- 1 file changed, 29 insertions(+), 85 deletions(-) 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 9c4c1672a..e05801f2d 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator.ts @@ -1,8 +1,5 @@ import type { AgentChatRepo } from "@App/app/repo/agent_chat"; -import type { - ScriptToolCallback, - ToolExecutorLike, -} from "@App/app/service/agent/core/tool_registry"; +import type { ScriptToolCallback, ToolExecutorLike } from "@App/app/service/agent/core/tool_registry"; import type { AgentModelConfig, ChatRequest, @@ -16,10 +13,7 @@ import type { } 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 { detectToolCallIssues, type ToolCallRecord } from "@App/app/service/agent/core/tool_call_guard"; import { elideOldToolResults } from "@App/app/service/agent/core/context_elision"; import type { LLMCallResult } from "./llm_client"; @@ -39,16 +33,9 @@ type AskUserForGuard = (question: string, options: string[]) => Promise; * Provider 归一化后的实际上下文输入 token。 * Anthropic 将缓存命中/写入 token 与断点后的 input_tokens 分开返回;OpenAI 的 prompt_tokens 已含缓存部分。 */ -function getContextInputTokens( - model: AgentModelConfig, - usage: NonNullable, -): number { +function getContextInputTokens(model: AgentModelConfig, usage: NonNullable): number { if (model.provider !== "anthropic") return usage.inputTokens; - return ( - usage.inputTokens + - (usage.cacheCreationInputTokens || 0) + - (usage.cacheReadInputTokens || 0) - ); + return usage.inputTokens + (usage.cacheCreationInputTokens || 0) + (usage.cacheReadInputTokens || 0); } /** 等待 loop-guard 回答;AbortSignal 触发时立即返回 null,不再阻塞会话停止/断开。 */ @@ -56,7 +43,7 @@ function waitForGuardAnswer( askUserForGuard: AskUserForGuard, question: string, options: string[], - signal: AbortSignal, + signal: AbortSignal ): Promise { if (signal.aborted) return Promise.resolve(null); @@ -95,14 +82,14 @@ export interface ToolLoopDeps { cache?: boolean; }, sendEvent: (event: ChatStreamEvent) => void, - signal: AbortSignal, + signal: AbortSignal ): Promise; autoCompact( conversationId: string, model: AgentModelConfig, messages: ChatRequest["messages"], sendEvent: (event: ChatStreamEvent) => void, - signal: AbortSignal, + signal: AbortSignal ): Promise; } @@ -112,7 +99,7 @@ export class ToolLoopOrchestrator { // 保证并发会话各自使用独立的工具注册表,避免闭包互相覆盖。 constructor( private deps: ToolLoopDeps, - private chatRepo: AgentChatRepo, + private chatRepo: AgentChatRepo ) {} // 统一的 tool calling 循环,UI 和脚本共用 @@ -173,9 +160,7 @@ export class ToolLoopOrchestrator { iterations++; // 每轮重新获取工具定义(load_skill 可能动态注册了新工具) - let allToolDefs = params.skipBuiltinTools - ? tools || [] - : toolRegistry.getDefinitions(tools); + 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)); @@ -190,7 +175,7 @@ export class ToolLoopOrchestrator { cache: params.cache, }, sendEvent, - signal, + signal ); if (signal.aborted) return; @@ -199,10 +184,8 @@ export class ToolLoopOrchestrator { 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; + totalUsage.cacheCreationInputTokens += result.usage.cacheCreationInputTokens || 0; + totalUsage.cacheReadInputTokens += result.usage.cacheReadInputTokens || 0; } // 自动 compact:当上下文占用超过 80% 时触发;否则按阈值分批裁剪旧 tool 结果(见下方 pendingElision) @@ -210,23 +193,13 @@ export class ToolLoopOrchestrator { let contextUsageRatio: number | null = null; if (result.usage && conversationId) { const contextWindow = getContextWindow(model); - contextUsageRatio = - getContextInputTokens(model, result.usage) / contextWindow; + contextUsageRatio = getContextInputTokens(model, result.usage) / contextWindow; if (contextUsageRatio >= 0.8) { - await this.deps.autoCompact( - conversationId, - model, - messages, - sendEvent, - signal, - ); + await this.deps.autoCompact(conversationId, model, messages, sendEvent, signal); } else { for (let i = 0; i < ELISION_THRESHOLDS.length; i++) { - if ( - i > lastElisionThresholdIndex && - contextUsageRatio >= ELISION_THRESHOLDS[i] - ) { + if (i > lastElisionThresholdIndex && contextUsageRatio >= ELISION_THRESHOLDS[i]) { pendingElision = true; lastElisionThresholdIndex = i; } @@ -238,8 +211,7 @@ export class ToolLoopOrchestrator { const buildMessageContent = (): MessageContent => { if (result.contentBlocks && result.contentBlocks.length > 0) { const blocks: ContentBlock[] = []; - if (result.content) - blocks.push({ type: "text", text: result.content }); + if (result.content) blocks.push({ type: "text", text: result.content }); blocks.push(...result.contentBlocks); return blocks; } @@ -247,11 +219,7 @@ export class ToolLoopOrchestrator { }; // 如果有 tool calls,需要执行并继续循环 - if ( - result.toolCalls && - result.toolCalls.length > 0 && - allToolDefs.length > 0 - ) { + if (result.toolCalls && result.toolCalls.length > 0 && allToolDefs.length > 0) { // 持久化 assistant 消息(含 tool calls) if (conversationId) { await this.chatRepo.appendMessage({ @@ -259,9 +227,7 @@ export class ToolLoopOrchestrator { conversationId, role: "assistant", content: buildMessageContent(), - thinking: result.thinking - ? { content: result.thinking } - : undefined, + thinking: result.thinking ? { content: result.thinking } : undefined, toolCalls: result.toolCalls, createtime: Date.now(), }); @@ -277,14 +243,8 @@ export class ToolLoopOrchestrator { // 通过 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, - ); + params.excludeTools && params.excludeTools.length > 0 ? new Set(params.excludeTools) : undefined; + const toolResults = await toolRegistry.execute(result.toolCalls, scriptToolCallback, excludeToolsSet); // 将 tool 结果加入消息,并通知 UI 工具执行完成 // 收集需要回写的 toolCall 元数据(执行状态 / 附件 / 子代理详情) @@ -343,9 +303,7 @@ export class ToolLoopOrchestrator { // 内存上下文中的 assistant 消息 const assistantMsg = messages.find( - (m) => - m.role === "assistant" && - m.toolCalls?.some((tc: ToolCall) => completedIds.has(tc.id)), + (m) => m.role === "assistant" && m.toolCalls?.some((tc: ToolCall) => completedIds.has(tc.id)) ); if (assistantMsg?.toolCalls) applyToolUpdates(assistantMsg.toolCalls); @@ -354,10 +312,7 @@ export class ToolLoopOrchestrator { 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)) - ) { + if (msg.role === "assistant" && msg.toolCalls?.some((tc: ToolCall) => completedIds.has(tc.id))) { applyToolUpdates(msg.toolCalls!); await this.chatRepo.saveMessages(conversationId, allMessages); break; @@ -379,10 +334,7 @@ export class ToolLoopOrchestrator { // 工具调用模式检测:检测重复/循环模式并注入针对性提醒 // 每次警告后推进 startIndex,避免旧记录持续触发同一条警告 - const toolCallWarning = detectToolCallIssues( - toolCallHistory, - guardStartIndex, - ); + const toolCallWarning = detectToolCallIssues(toolCallHistory, guardStartIndex); if (toolCallWarning) { guardStartIndex = toolCallHistory.length; messages.push({ role: "user", content: toolCallWarning }); @@ -390,32 +342,26 @@ export class ToolLoopOrchestrator { guardStrikeCount++; // 连续命中达到阈值时暂停,询问用户是否继续(仅 UI 对话传入 askUserForGuard 时生效) - if ( - guardStrikeCount >= GUARD_ESCALATION_STRIKES && - params.askUserForGuard - ) { + if (guardStrikeCount >= GUARD_ESCALATION_STRIKES && params.askUserForGuard) { const answer = await waitForGuardAnswer( params.askUserForGuard, `[System] The Agent has triggered the loop-guard warning ${guardStrikeCount} times since the last check. Continue letting it proceed automatically, or stop here?`, ["Continue", GUARD_STOP_ANSWER], - signal, + signal ); if (answer === null) { const durationMs = Date.now() - startTime; sendEvent({ type: "done", usage: totalUsage, durationMs }); return; } - if ( - answer.trim().toLowerCase() === GUARD_STOP_ANSWER.toLowerCase() - ) { + if (answer.trim().toLowerCase() === GUARD_STOP_ANSWER.toLowerCase()) { const durationMs = Date.now() - startTime; if (conversationId) { await this.chatRepo.appendMessage({ id: uuidv4(), conversationId, role: "assistant", - content: - "Stopped at the user's request after repeated loop-guard warnings.", + content: "Stopped at the user's request after repeated loop-guard warnings.", usage: totalUsage, durationMs, createtime: Date.now(), @@ -434,10 +380,8 @@ export class ToolLoopOrchestrator { if ( !pendingElision && contextUsageRatio !== null && - contextUsageRatio >= - ELISION_THRESHOLDS[ELISION_THRESHOLDS.length - 1] && - assistantToolTurns - lastElisionAssistantTurn >= - ELISION_KEEP_LAST_ASSISTANT_TURNS + contextUsageRatio >= ELISION_THRESHOLDS[ELISION_THRESHOLDS.length - 1] && + assistantToolTurns - lastElisionAssistantTurn >= ELISION_KEEP_LAST_ASSISTANT_TURNS ) { pendingElision = true; } From cc1261e91b76cfef32244eb11a0aadb3c2d98dc4 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 07:36:28 +0900 Subject: [PATCH 09/38] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20Agent?= =?UTF-8?q?=20=E5=AF=B9=E8=AF=9D=E7=BB=AD=E6=8E=A5=E4=B8=8E=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E7=BB=93=E6=9E=9C=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/agent/core/persisted_messages.ts | 15 +++++++++ .../agent/core/tools/execute_script.test.ts | 11 +++++++ .../agent/core/tools/execute_script.ts | 26 ++++++++++----- src/app/service/agent/core/types.ts | 1 + .../agent/service_worker/background.test.ts | 3 +- .../background_session_manager.ts | 3 ++ .../agent/service_worker/chat_service.ts | 32 ++++--------------- .../agent/service_worker/task_service.ts | 11 +++---- .../tool_loop_orchestrator.test.ts | 22 +++++++++++++ .../service_worker/tool_loop_orchestrator.ts | 5 ++- .../options/routes/Agent/Chat/ChatArea.tsx | 7 +++- .../routes/Agent/Chat/chat_utils.test.ts | 18 +++++++++++ .../options/routes/Agent/Chat/chat_utils.ts | 8 +++++ src/pages/options/routes/Agent/Chat/hooks.ts | 2 ++ 14 files changed, 120 insertions(+), 44 deletions(-) create mode 100644 src/app/service/agent/core/persisted_messages.ts 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/tools/execute_script.test.ts b/src/app/service/agent/core/tools/execute_script.test.ts index b3b07da79..3d5b1f112 100644 --- a/src/app/service/agent/core/tools/execute_script.test.ts +++ b/src/app/service/agent/core/tools/execute_script.test.ts @@ -118,6 +118,17 @@ describe("execute_script 工具", () => { }); describe("超大返回值截断", () => { + 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 }); diff --git a/src/app/service/agent/core/tools/execute_script.ts b/src/app/service/agent/core/tools/execute_script.ts index b1ca8f370..167c24c95 100644 --- a/src/app/service/agent/core/tools/execute_script.ts +++ b/src/app/service/agent/core/tools/execute_script.ts @@ -32,8 +32,6 @@ const EXECUTE_SCRIPT_TIMEOUT_MS = 30_000; // 返回值过大时(如 DOM dump、模块映射)截断,避免其在后续每轮 tool loop 中被完整重复计费 const MAX_RESULT_CHARS = 30_000; -const HEAD_CHARS = 15_000; -const TAIL_CHARS = 15_000; /** 将 result 序列化,超过阈值时截断为首尾各一部分并标注 truncated */ function buildResultPayload(result: unknown, extra: Record): string { @@ -42,12 +40,24 @@ function buildResultPayload(result: unknown, extra: Record): st if (resultStr.length <= MAX_RESULT_CHARS) { return JSON.stringify({ result: rawResult, ...extra }); } - const omitted = resultStr.length - HEAD_CHARS - TAIL_CHARS; - const truncatedText = - resultStr.slice(0, HEAD_CHARS) + - `\n…[truncated ${omitted} chars — return a smaller value or write large data to OPFS via opfs_write]…\n` + - resultStr.slice(resultStr.length - TAIL_CHARS); - return JSON.stringify({ result: truncatedText, ...extra, truncated: true, original_length: resultStr.length }); + 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 = { diff --git a/src/app/service/agent/core/types.ts b/src/app/service/agent/core/types.ts index 5da995ae6..45ea27578 100644 --- a/src/app/service/agent/core/types.ts +++ b/src/app/service/agent/core/types.ts @@ -146,6 +146,7 @@ export type ForwardableEvent = export type ChatStreamEvent = | (ForwardableEvent & { subAgent?: SubAgentEventInfo }) | { type: "ask_user"; id: string; question: string; options?: string[]; multiple?: boolean } + | { type: "ask_user_expired"; id: string } | { type: "system_warning"; message: string } | { type: "task_update"; diff --git a/src/app/service/agent/service_worker/background.test.ts b/src/app/service/agent/service_worker/background.test.ts index b5a708c1f..00563054e 100644 --- a/src/app/service/agent/service_worker/background.test.ts +++ b/src/app/service/agent/service_worker/background.test.ts @@ -644,7 +644,7 @@ describe("后台运行会话 集成测试", () => { try { const { service, mockRepo } = createTestService(); setupConversation(mockRepo); - const { sender } = createMockSender(); + const { sender, sentMessages } = createMockSender(); const registry = (service as any).toolRegistry; registry.registerBuiltin( @@ -677,6 +677,7 @@ describe("后台运行会话 集成测试", () => { expect(rc).toBeDefined(); expect(rc.status).toBe("running"); expect(rc.pendingAskUser).toBeUndefined(); + expect(sentMessages.some((message) => message.data?.type === "ask_user_expired")).toBe(true); registry.unregisterBuiltin("dup"); } finally { 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..6f7cb9f34 100644 --- a/src/app/service/agent/service_worker/background_session_manager.ts +++ b/src/app/service/agent/service_worker/background_session_manager.ts @@ -107,6 +107,9 @@ export class BackgroundSessionManager { multiple: event.multiple, }; break; + case "ask_user_expired": + if (rc.pendingAskUser?.id === event.id) rc.pendingAskUser = undefined; + break; case "task_update": rc.tasks = event.tasks; break; diff --git a/src/app/service/agent/service_worker/chat_service.ts b/src/app/service/agent/service_worker/chat_service.ts index 45e0b17d7..70c7f4daf 100644 --- a/src/app/service/agent/service_worker/chat_service.ts +++ b/src/app/service/agent/service_worker/chat_service.ts @@ -35,6 +35,7 @@ import { createExecuteScriptTool } from "@App/app/service/agent/core/tools/execu 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 type { LLMCallResult } from "./llm_client"; @@ -205,6 +206,7 @@ export class ChatService { const timer = setTimeout( () => { askResolvers.delete(askId); + sendEvent({ type: "ask_user_expired", id: askId }); // 后台会话:清除过期的 pendingAskUser,避免后续 attach 的 UI 看到已超时的提问 if (rc && rc.pendingAskUser?.id === askId) rc.pendingAskUser = undefined; resolve("Continue"); @@ -413,12 +415,7 @@ export class ChatService { // 添加脚本端维护的消息历史(已含最新 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, - }); + messages.push(...toLLMMessages([msg])); } } @@ -429,7 +426,7 @@ export class ChatService { model, messages, tools: params.tools, - maxIterations: params.maxIterations || 20, + maxIterations: normalizeChatMaxIterations(params.maxIterations ?? 20), sendEvent, signal: abortController.signal, scriptToolCallback: params.tools && params.tools.length > 0 ? scriptToolCallback : null, @@ -464,15 +461,7 @@ export class ChatService { 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(...toLLMMessages(existingMessages).filter((msg) => msg.role !== "system")); summaryMessages.push({ role: "user", content: buildCompactUserPrompt(params.compactInstruction) }); @@ -680,16 +669,7 @@ export class ChatService { // 添加历史消息(跳过 system;跳过错误占位消息 —— 如超过 max_iterations 时持久化的空 content // assistant 消息,仅用于 UI 展示"继续对话"操作,重放给 LLM 会产生空 content 的无意义消息, // 部分 provider(如 Anthropic)甚至会因空 content 拒绝请求) - for (const msg of existingMessages) { - if (msg.role === "system") continue; - if (msg.error) continue; - messages.push({ - role: msg.role, - content: msg.content, - toolCallId: msg.toolCallId, - toolCalls: msg.toolCalls, - }); - } + messages.push(...toLLMMessages(existingMessages).filter((msg) => msg.role !== "system")); if (!params.skipSaveUserMessage) { // 添加新用户消息到 LLM 上下文并持久化 diff --git a/src/app/service/agent/service_worker/task_service.ts b/src/app/service/agent/service_worker/task_service.ts index f5101b52f..b415a0eef 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 { @@ -115,12 +117,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 { @@ -167,7 +164,7 @@ export class AgentTaskService { toolRegistry: sessionRegistry, model, messages, - maxIterations: task.maxIterations || 10, + maxIterations: normalizeChatMaxIterations(task.maxIterations ?? 10), sendEvent, signal: abortController.signal, scriptToolCallback: null, 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 index efa9e02ca..bba033e70 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator.test.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator.test.ts @@ -88,6 +88,28 @@ describe("ToolLoopOrchestrator 循环检测升级(loop-guard escalation)", ( expect(doneEvents).toHaveLength(1); }); + 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 })); + }); + it("第 2 次触发告警时应暂停并询问用户;回答非 Stop 时应继续循环", async () => { callLLM .mockResolvedValueOnce(dupToolCallResult("c1")) 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 e05801f2d..bbc758c91 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator.ts @@ -129,7 +129,7 @@ export class ToolLoopOrchestrator { const { toolRegistry, model, - messages, + messages: inputMessages, tools, maxIterations, sendEvent, @@ -137,6 +137,9 @@ export class ToolLoopOrchestrator { scriptToolCallback, conversationId, } = params; + // 持久化历史保留完整结果供 UI 展示;LLM 只使用独立副本,续接时首个请求也先裁剪旧结果。 + const messages = conversationId ? inputMessages.map((message) => ({ ...message })) : inputMessages; + if (conversationId) elideOldToolResults(messages, ELISION_KEEP_LAST_ASSISTANT_TURNS); const startTime = Date.now(); let iterations = 0; diff --git a/src/pages/options/routes/Agent/Chat/ChatArea.tsx b/src/pages/options/routes/Agent/Chat/ChatArea.tsx index 0a7832bcb..38171ed30 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"; @@ -731,7 +732,11 @@ export default function ChatArea({ onCopy={() => handleCopy(group.messages)} onRegenerate={() => handleRegenerate(messageGroups, groupIndex)} onDelete={() => handleDeleteRound(messageGroups, groupIndex)} - onContinue={isStreaming ? undefined : () => void handleSend(t("agent:chat_continue_message"))} + onContinue={ + !isStreaming && canContinueMaxIterationsGroup(group, groupIndex === messageGroups.length - 1) + ? () => void handleSend(t("agent:chat_continue_message")) + : undefined + } /> ) ) diff --git a/src/pages/options/routes/Agent/Chat/chat_utils.test.ts b/src/pages/options/routes/Agent/Chat/chat_utils.test.ts index 690cb2a62..693f38126 100644 --- a/src/pages/options/routes/Agent/Chat/chat_utils.test.ts +++ b/src/pages/options/routes/Agent/Chat/chat_utils.test.ts @@ -7,6 +7,7 @@ import { computeEditAction, computeUserRegenerateAction, findNextAssistantGroupIndex, + canContinueMaxIterationsGroup, } from "./chat_utils"; // 辅助函数:创建测试消息 @@ -103,6 +104,23 @@ describe("mergeToolResults", () => { }); }); +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.ts b/src/pages/options/routes/Agent/Chat/hooks.ts index dac8f3690..90951386a 100644 --- a/src/pages/options/routes/Agent/Chat/hooks.ts +++ b/src/pages/options/routes/Agent/Chat/hooks.ts @@ -212,6 +212,7 @@ export function useStreamingChat() { multiple: event.multiple, }); } + if (event.type === "ask_user_expired") setAskUserPending(null); onEvent(event); if ((event.type === "done" || event.type === "error") && !("subAgent" in event && event.subAgent)) { setIsStreaming(false); @@ -260,6 +261,7 @@ export function useStreamingChat() { multiple: event.multiple, }); } + if (event.type === "ask_user_expired") setAskUserPending(null); onEvent(event); From bb142ca6e0fe39945447a52e8a105c1026056c41 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 07:49:51 +0900 Subject: [PATCH 10/38] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=9D=E6=8C=81?= =?UTF-8?q?=E4=B8=B4=E6=97=B6=20Agent=20=E5=AF=B9=E8=AF=9D=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/agent/service_worker/chat_service.ts | 1 + src/app/service/agent/service_worker/task_service.ts | 2 ++ .../agent/service_worker/tool_loop_orchestrator.test.ts | 1 + .../service/agent/service_worker/tool_loop_orchestrator.ts | 7 +++++-- 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/app/service/agent/service_worker/chat_service.ts b/src/app/service/agent/service_worker/chat_service.ts index 70c7f4daf..a8d182d7a 100644 --- a/src/app/service/agent/service_worker/chat_service.ts +++ b/src/app/service/agent/service_worker/chat_service.ts @@ -354,6 +354,7 @@ export class ChatService { signal: abortController.signal, scriptToolCallback: enableTools && params.tools && params.tools.length > 0 ? scriptToolCallback : null, conversationId: params.conversationId, + rehydratedHistory: true, skipBuiltinTools: !enableTools, askUserForGuard, }); diff --git a/src/app/service/agent/service_worker/task_service.ts b/src/app/service/agent/service_worker/task_service.ts index b415a0eef..9e0ea73ab 100644 --- a/src/app/service/agent/service_worker/task_service.ts +++ b/src/app/service/agent/service_worker/task_service.ts @@ -36,6 +36,7 @@ export interface TaskOrchestrator { signal: AbortSignal; scriptToolCallback: ScriptToolCallback | null; conversationId: string; + rehydratedHistory?: boolean; }): Promise; } @@ -169,6 +170,7 @@ export class AgentTaskService { signal: abortController.signal, scriptToolCallback: null, conversationId, + rehydratedHistory: Boolean(task.conversationId), }); // 通知 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 index bba033e70..856886ff2 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator.test.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator.test.ts @@ -67,6 +67,7 @@ describe("ToolLoopOrchestrator 循环检测升级(loop-guard escalation)", ( signal: new AbortController().signal, scriptToolCallback: null, conversationId: "conv-1", + rehydratedHistory: true, ...overrides, }; } 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 bbc758c91..4f71af5e3 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator.ts @@ -122,6 +122,8 @@ export class ToolLoopOrchestrator { excludeTools?: string[]; // 是否启用 prompt caching,默认 true cache?: boolean; + // 消息来自持久化历史时,先在独立副本上裁剪旧 tool 结果。 + rehydratedHistory?: boolean; // 循环检测连续命中达到 GUARD_ESCALATION_STRIKES 次时调用,暂停循环询问用户是否继续。 // 仅由 UI 对话(含后台会话)传入;定时任务、子代理不传,保持原有的仅告警不暂停行为。 askUserForGuard?: AskUserForGuard; @@ -136,10 +138,11 @@ export class ToolLoopOrchestrator { signal, scriptToolCallback, conversationId, + rehydratedHistory, } = params; // 持久化历史保留完整结果供 UI 展示;LLM 只使用独立副本,续接时首个请求也先裁剪旧结果。 - const messages = conversationId ? inputMessages.map((message) => ({ ...message })) : inputMessages; - if (conversationId) elideOldToolResults(messages, ELISION_KEEP_LAST_ASSISTANT_TURNS); + const messages = rehydratedHistory ? inputMessages.map((message) => ({ ...message })) : inputMessages; + if (rehydratedHistory) elideOldToolResults(messages, ELISION_KEEP_LAST_ASSISTANT_TURNS); const startTime = Date.now(); let iterations = 0; From ad31fd2d12eb9b8df30c46227fbbbb6b4fd66c29 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:15:53 +0900 Subject: [PATCH 11/38] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20Agent?= =?UTF-8?q?=20=E4=BB=BB=E5=8A=A1=E7=BB=88=E6=80=81=E4=B8=8E=E5=BE=AA?= =?UTF-8?q?=E7=8E=AF=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/agent/core/task_scheduler.test.ts | 16 +++++ src/app/service/agent/core/task_scheduler.ts | 1 + .../agent/core/tools/execute_script.test.ts | 12 ++++ .../agent/core/tools/execute_script.ts | 5 +- src/app/service/agent/core/types.ts | 28 +++++++- .../agent/service_worker/autocompact.test.ts | 57 ++++++++++++++++ .../agent/service_worker/background.test.ts | 3 +- .../background_session_manager.ts | 15 +++- .../agent/service_worker/chat_service.ts | 39 ++++++++--- .../service/agent/service_worker/llm.test.ts | 7 ++ .../agent/service_worker/task_service.ts | 8 ++- .../tool_loop_orchestrator.test.ts | 28 ++++++-- .../service_worker/tool_loop_orchestrator.ts | 32 +++++---- src/locales/de-DE/agent.json | 4 ++ src/locales/en-US/agent.json | 4 ++ src/locales/ja-JP/agent.json | 4 ++ src/locales/ru-RU/agent.json | 4 ++ src/locales/tr-TR/agent.json | 4 ++ src/locales/vi-VN/agent.json | 4 ++ src/locales/zh-CN/agent.json | 4 ++ src/locales/zh-TW/agent.json | 4 ++ .../routes/Agent/Chat/AskUserBlock.test.tsx | 18 +++++ .../routes/Agent/Chat/AskUserBlock.tsx | 68 +++++++++++-------- .../options/routes/Agent/Chat/ChatArea.tsx | 3 + src/pages/options/routes/Agent/Chat/hooks.ts | 15 +++- 25 files changed, 317 insertions(+), 70 deletions(-) diff --git a/src/app/service/agent/core/task_scheduler.test.ts b/src/app/service/agent/core/task_scheduler.test.ts index a67c400ee..0560a246b 100644 --- a/src/app/service/agent/core/task_scheduler.test.ts +++ b/src/app/service/agent/core/task_scheduler.test.ts @@ -212,6 +212,22 @@ describe("AgentTaskScheduler", () => { expect(updatedTask!.lastRunError).toBe("LLM 调用失败"); }); + it("执行失败时保留已累计的 usage", async () => { + internalExecutor.mockRejectedValue( + Object.assign(new Error("超过最大迭代次数"), { + usage: { inputTokens: 120, outputTokens: 40 }, + }) + ); + + 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 }); + }); + 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..2a774baf5 100644 --- a/src/app/service/agent/core/task_scheduler.ts +++ b/src/app/service/agent/core/task_scheduler.ts @@ -83,6 +83,7 @@ export class AgentTaskScheduler { } catch (e: any) { run.status = "error"; run.error = e.message || "Unknown error"; + if (e.usage) run.usage = e.usage; run.endtime = Date.now(); task.lastRunStatus = "error"; 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 3d5b1f112..0fac12fdc 100644 --- a/src/app/service/agent/core/tools/execute_script.test.ts +++ b/src/app/service/agent/core/tools/execute_script.test.ts @@ -118,6 +118,18 @@ describe("execute_script 工具", () => { }); 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) }); diff --git a/src/app/service/agent/core/tools/execute_script.ts b/src/app/service/agent/core/tools/execute_script.ts index 167c24c95..a9a30a317 100644 --- a/src/app/service/agent/core/tools/execute_script.ts +++ b/src/app/service/agent/core/tools/execute_script.ts @@ -37,9 +37,8 @@ const MAX_RESULT_CHARS = 30_000; function buildResultPayload(result: unknown, extra: Record): string { const rawResult = result ?? null; const resultStr = JSON.stringify(rawResult); - if (resultStr.length <= MAX_RESULT_CHARS) { - return JSON.stringify({ result: rawResult, ...extra }); - } + 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; diff --git a/src/app/service/agent/core/types.ts b/src/app/service/agent/core/types.ts index 45ea27578..dd809b09d 100644 --- a/src/app/service/agent/core/types.ts +++ b/src/app/service/agent/core/types.ts @@ -138,15 +138,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"; @@ -161,7 +176,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; diff --git a/src/app/service/agent/service_worker/autocompact.test.ts b/src/app/service/agent/service_worker/autocompact.test.ts index 9544fac08..a0ae36470 100644 --- a/src/app/service/agent/service_worker/autocompact.test.ts +++ b/src/app/service/agent/service_worker/autocompact.test.ts @@ -99,6 +99,63 @@ 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", + contextWindow: 1000, + }); + 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(100), + 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(); diff --git a/src/app/service/agent/service_worker/background.test.ts b/src/app/service/agent/service_worker/background.test.ts index 00563054e..e65faab52 100644 --- a/src/app/service/agent/service_worker/background.test.ts +++ b/src/app/service/agent/service_worker/background.test.ts @@ -356,7 +356,7 @@ describe("handleAttachToConversation 重连逻辑", () => { 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 @@ -365,6 +365,7 @@ describe("handleAttachToConversation 重连逻辑", () => { expect(resolvedAnswer).toBe("红色"); expect(rc.pendingAskUser).toBeUndefined(); expect(rc.askResolvers.has("ask-1")).toBe(false); + expect(sentMessages.some((message) => message.data?.type === "ask_user_resolved")).toBe(true); (service as any).bgSessionManager.delete("conv-ask"); }); 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 6f7cb9f34..ca3a03c43 100644 --- a/src/app/service/agent/service_worker/background_session_manager.ts +++ b/src/app/service/agent/service_worker/background_session_manager.ts @@ -13,7 +13,14 @@ 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"; @@ -104,12 +111,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; @@ -187,6 +199,7 @@ export class BackgroundSessionManager { if (resolver) { rc.askResolvers.delete(msg.data.id); rc.pendingAskUser = undefined; + sendEvent({ type: "ask_user_resolved", id: msg.data.id }); resolver(msg.data.answer); } } diff --git a/src/app/service/agent/service_worker/chat_service.ts b/src/app/service/agent/service_worker/chat_service.ts index a8d182d7a..e09af5c49 100644 --- a/src/app/service/agent/service_worker/chat_service.ts +++ b/src/app/service/agent/service_worker/chat_service.ts @@ -37,6 +37,8 @@ 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 { t } from "@App/locales/locales"; +import { elideOldToolResults } from "@App/app/service/agent/core/context_elision"; import type { LLMCallResult } from "./llm_client"; /** ChatService 需要的 execute_script 工具依赖 */ @@ -199,24 +201,36 @@ export class ChatService { // 循环检测(tool_call_guard)连续命中时暂停询问用户是否继续;复用 ask_user 的事件/resolver 机制, // 5 分钟无人应答时默认"继续",避免无 UI 监听的后台会话被无限期挂起 - const askUserForGuard = (question: string, options: string[]): Promise => { + const askUserForGuard = (strikeCount: number): Promise => { return new Promise((resolve) => { const askId = `guard_${uuidv4()}`; - sendEvent({ type: "ask_user", id: askId, question, options, multiple: false }); + const cleanup = () => abortController.signal.removeEventListener("abort", onAbort); + const finish = (answer: string) => { + clearTimeout(timer); + askResolvers.delete(askId); + cleanup(); + resolve(answer); + }; + const onAbort = () => finish("stop"); const timer = setTimeout( () => { - askResolvers.delete(askId); sendEvent({ type: "ask_user_expired", id: askId }); - // 后台会话:清除过期的 pendingAskUser,避免后续 attach 的 UI 看到已超时的提问 - if (rc && rc.pendingAskUser?.id === askId) rc.pendingAskUser = undefined; - resolve("Continue"); + finish("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) => { - clearTimeout(timer); - askResolvers.delete(askId); - resolve(answer); + finish(answer); }); }); }; @@ -258,6 +272,7 @@ export class ChatService { if (resolver) { askResolvers.delete(msg.data.id); if (rc) rc.pendingAskUser = undefined; + sendEvent({ type: "ask_user_resolved", id: msg.data.id }); resolver(msg.data.answer); } } @@ -452,8 +467,9 @@ export class ChatService { 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 (existingMessages.filter((m) => m.role !== "system").length === 0) { + if (historyMessages.length === 0) { sendEvent({ type: "error", message: "No messages to compact" }); return; } @@ -462,7 +478,8 @@ export class ChatService { const summaryMessages: ChatRequest["messages"] = []; summaryMessages.push({ role: "system", content: COMPACT_SYSTEM_PROMPT }); - summaryMessages.push(...toLLMMessages(existingMessages).filter((msg) => msg.role !== "system")); + summaryMessages.push(...historyMessages); + elideOldToolResults(summaryMessages, 5); summaryMessages.push({ role: "user", content: buildCompactUserPrompt(params.compactInstruction) }); diff --git a/src/app/service/agent/service_worker/llm.test.ts b/src/app/service/agent/service_worker/llm.test.ts index 051991570..ccc06c6f4 100644 --- a/src/app/service/agent/service_worker/llm.test.ts +++ b/src/app/service/agent/service_worker/llm.test.ts @@ -512,6 +512,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); diff --git a/src/app/service/agent/service_worker/task_service.ts b/src/app/service/agent/service_worker/task_service.ts index 9e0ea73ab..1e4db968f 100644 --- a/src/app/service/agent/service_worker/task_service.ts +++ b/src/app/service/agent/service_worker/task_service.ts @@ -155,10 +155,16 @@ 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; } + if (event.type === "error") { + throw Object.assign(new Error(event.message), { + errorCode: event.errorCode, + usage: totalUsage, + }); + } }; await this.orchestrator.callLLMWithToolLoop({ 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 index 856886ff2..ebe98a2fe 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator.test.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator.test.ts @@ -108,6 +108,24 @@ describe("ToolLoopOrchestrator 循环检测升级(loop-guard escalation)", ( 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 })); }); @@ -124,9 +142,7 @@ describe("ToolLoopOrchestrator 循环检测升级(loop-guard escalation)", ( await orchestrator.callLLMWithToolLoop(baseParams({ askUserForGuard })); expect(askUserForGuard).toHaveBeenCalledTimes(1); - const [question, options] = askUserForGuard.mock.calls[0]; - expect(question).toContain("2"); - expect(options).toContain("Stop"); + expect(askUserForGuard.mock.calls[0][0]).toBe(2); expect(callLLM).toHaveBeenCalledTimes(5); const doneEvents = sendEvent.mock.calls.filter((c) => c[0].type === "done"); @@ -155,7 +171,7 @@ describe("ToolLoopOrchestrator 循环检测升级(loop-guard escalation)", ( // 停止信息应被持久化 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" && m.content.includes("Stop")); + const stopMessage = assistantCalls.find((m: any) => typeof m.content === "string"); expect(stopMessage).toBeDefined(); }); @@ -175,8 +191,8 @@ describe("ToolLoopOrchestrator 循环检测升级(loop-guard escalation)", ( // 仅在第 4 轮和第 8 轮各暂停一次,中间第 6 轮的命中(重置后第 1 次)不应触发暂停 expect(askUserForGuard).toHaveBeenCalledTimes(2); - expect(askUserForGuard.mock.calls[0][0]).toContain("2"); - expect(askUserForGuard.mock.calls[1][0]).toContain("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"); 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 4f71af5e3..cf045ce3a 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator.ts @@ -16,6 +16,7 @@ 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 } 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 稳定性。 @@ -25,9 +26,9 @@ const ELISION_KEEP_LAST_ASSISTANT_TURNS = 5; // 循环检测(tool_call_guard)连续命中达到此次数时,暂停并询问用户是否继续(仅当调用方提供 askUserForGuard 时生效) const GUARD_ESCALATION_STRIKES = 2; -const GUARD_STOP_ANSWER = "Stop"; +const GUARD_STOP_ANSWER = "stop"; -type AskUserForGuard = (question: string, options: string[]) => Promise; +type AskUserForGuard = (strikeCount: number) => Promise; /** * Provider 归一化后的实际上下文输入 token。 @@ -41,8 +42,7 @@ function getContextInputTokens(model: AgentModelConfig, usage: NonNullable { if (signal.aborted) return Promise.resolve(null); @@ -66,7 +66,7 @@ function waitForGuardAnswer( signal.addEventListener("abort", onAbort, { once: true }); Promise.resolve() - .then(() => askUserForGuard(question, options)) + .then(() => askUserForGuard(strikeCount)) .then(resolveOnce, rejectOnce); }); } @@ -142,7 +142,13 @@ export class ToolLoopOrchestrator { } = params; // 持久化历史保留完整结果供 UI 展示;LLM 只使用独立副本,续接时首个请求也先裁剪旧结果。 const messages = rehydratedHistory ? inputMessages.map((message) => ({ ...message })) : inputMessages; - if (rehydratedHistory) elideOldToolResults(messages, ELISION_KEEP_LAST_ASSISTANT_TURNS); + if (rehydratedHistory) { + const estimatedInputTokens = JSON.stringify(messages).length / 4; + const estimatedUsageRatio = estimatedInputTokens / getContextWindow(model); + if (estimatedUsageRatio >= ELISION_THRESHOLDS[0]) { + elideOldToolResults(messages, ELISION_KEEP_LAST_ASSISTANT_TURNS); + } + } const startTime = Date.now(); let iterations = 0; @@ -349,12 +355,7 @@ export class ToolLoopOrchestrator { // 连续命中达到阈值时暂停,询问用户是否继续(仅 UI 对话传入 askUserForGuard 时生效) if (guardStrikeCount >= GUARD_ESCALATION_STRIKES && params.askUserForGuard) { - const answer = await waitForGuardAnswer( - params.askUserForGuard, - `[System] The Agent has triggered the loop-guard warning ${guardStrikeCount} times since the last check. Continue letting it proceed automatically, or stop here?`, - ["Continue", GUARD_STOP_ANSWER], - signal - ); + const answer = await waitForGuardAnswer(params.askUserForGuard, guardStrikeCount, signal); if (answer === null) { const durationMs = Date.now() - startTime; sendEvent({ type: "done", usage: totalUsage, durationMs }); @@ -367,7 +368,7 @@ export class ToolLoopOrchestrator { id: uuidv4(), conversationId, role: "assistant", - content: "Stopped at the user's request after repeated loop-guard warnings.", + content: t("agent:chat_guard_stopped_message"), usage: totalUsage, durationMs, createtime: Date.now(), @@ -426,6 +427,7 @@ export class ToolLoopOrchestrator { } // 超过最大迭代次数 + const durationMs = Date.now() - startTime; const maxIterMsg = `Tool calling loop exceeded maximum iterations (${maxIterations})`; if (conversationId) { await this.chatRepo.appendMessage({ @@ -435,6 +437,8 @@ export class ToolLoopOrchestrator { content: "", error: maxIterMsg, errorCode: "max_iterations", + usage: totalUsage, + durationMs, createtime: Date.now(), }); } @@ -442,6 +446,8 @@ export class ToolLoopOrchestrator { type: "error", message: maxIterMsg, errorCode: "max_iterations", + usage: totalUsage, + durationMs, }); } } diff --git a/src/locales/de-DE/agent.json b/src/locales/de-DE/agent.json index 0f0149c65..183cc6ee5 100644 --- a/src/locales/de-DE/agent.json +++ b/src/locales/de-DE/agent.json @@ -88,6 +88,10 @@ "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}})...", diff --git a/src/locales/en-US/agent.json b/src/locales/en-US/agent.json index 2d75d5a25..3e0d3700e 100644 --- a/src/locales/en-US/agent.json +++ b/src/locales/en-US/agent.json @@ -88,6 +88,10 @@ "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}})...", diff --git a/src/locales/ja-JP/agent.json b/src/locales/ja-JP/agent.json index 561866a03..346a3a746 100644 --- a/src/locales/ja-JP/agent.json +++ b/src/locales/ja-JP/agent.json @@ -88,6 +88,10 @@ "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}})...", diff --git a/src/locales/ru-RU/agent.json b/src/locales/ru-RU/agent.json index 24dcf83f9..24039a1b0 100644 --- a/src/locales/ru-RU/agent.json +++ b/src/locales/ru-RU/agent.json @@ -88,6 +88,10 @@ "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}})...", diff --git a/src/locales/tr-TR/agent.json b/src/locales/tr-TR/agent.json index 717e7f478..80db30b8d 100644 --- a/src/locales/tr-TR/agent.json +++ b/src/locales/tr-TR/agent.json @@ -88,6 +88,10 @@ "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}})...", diff --git a/src/locales/vi-VN/agent.json b/src/locales/vi-VN/agent.json index ec03bdab7..b76503385 100644 --- a/src/locales/vi-VN/agent.json +++ b/src/locales/vi-VN/agent.json @@ -88,6 +88,10 @@ "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}})...", diff --git a/src/locales/zh-CN/agent.json b/src/locales/zh-CN/agent.json index c8cb6ca0a..154ced59e 100644 --- a/src/locales/zh-CN/agent.json +++ b/src/locales/zh-CN/agent.json @@ -88,6 +88,10 @@ "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}})...", diff --git a/src/locales/zh-TW/agent.json b/src/locales/zh-TW/agent.json index 8f178eaf4..408bd5632 100644 --- a/src/locales/zh-TW/agent.json +++ b/src/locales/zh-TW/agent.json @@ -88,6 +88,10 @@ "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}})...", 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 38171ed30..82422c3a0 100644 --- a/src/pages/options/routes/Agent/Chat/ChatArea.tsx +++ b/src/pages/options/routes/Agent/Chat/ChatArea.tsx @@ -380,6 +380,7 @@ export default function ChatArea({ break; case "error": msg.error = event.message; + msg.errorCode = event.errorCode; break; case "done": if (event.usage) msg.usage = event.usage; @@ -746,7 +747,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/hooks.ts b/src/pages/options/routes/Agent/Chat/hooks.ts index 90951386a..a1fd74343 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() { @@ -209,10 +216,13 @@ export function useStreamingChat() { 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)) { setIsStreaming(false); @@ -258,10 +268,13 @@ export function useStreamingChat() { 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); From 769c15bf10e2bfd6a7b6878fd9140165c6ec8a6b Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:32:08 +0900 Subject: [PATCH 12/38] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20Agent?= =?UTF-8?q?=20=E9=80=82=E9=85=8D=E5=99=A8=E7=BB=88=E6=80=81=E4=B8=8E?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=87=E9=A2=84=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent/core/context_elision.test.ts | 36 ++++++++++++++++++- src/app/service/agent/core/context_elision.ts | 23 +++++++++++- .../service/agent/core/task_scheduler.test.ts | 2 ++ src/app/service/agent/core/task_scheduler.ts | 1 + .../agent/service_worker/background.test.ts | 6 ++-- .../background_session_manager.ts | 2 +- .../agent/service_worker/chat_service.ts | 11 ++++-- .../service_worker/sub_agent_service.test.ts | 25 +++++++++++++ .../agent/service_worker/sub_agent_service.ts | 1 + .../agent/service_worker/task_service.ts | 1 + .../service_worker/tool_loop_orchestrator.ts | 13 ++++--- .../service/content/gm_api/cat_agent.test.ts | 16 +++++++-- src/app/service/content/gm_api/cat_agent.ts | 6 ++-- .../options/routes/Agent/Chat/ChatArea.tsx | 5 +-- 14 files changed, 129 insertions(+), 19 deletions(-) diff --git a/src/app/service/agent/core/context_elision.test.ts b/src/app/service/agent/core/context_elision.test.ts index 4e1fe85f2..82cc7b686 100644 --- a/src/app/service/agent/core/context_elision.test.ts +++ b/src/app/service/agent/core/context_elision.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { elideOldToolResults, ELIDED_TOOL_RESULT_STUB } from "./context_elision"; +import { + elideOldToolResults, + ELIDED_TOOL_RESULT_STUB, + elideUntilWithinBudget, + estimateRequestTokens, +} from "./context_elision"; import type { ChatRequest } from "./types"; // 构造一轮 assistant(带 toolCalls) + N 条 tool 结果 @@ -90,3 +95,32 @@ describe("elideOldToolResults", () => { 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); + }); +}); diff --git a/src/app/service/agent/core/context_elision.ts b/src/app/service/agent/core/context_elision.ts index dfb61d3b3..50cbfcf10 100644 --- a/src/app/service/agent/core/context_elision.ts +++ b/src/app/service/agent/core/context_elision.ts @@ -5,6 +5,12 @@ import type { ChatRequest } from "./types"; export const ELIDED_TOOL_RESULT_STUB = "[tool result elided to save context — re-run the tool if you need this data again]"; +/** 以 UTF-8 字节数的保守上界估算 provider 请求 token。 */ +export function estimateRequestTokens(messages: ChatRequest["messages"], tools?: unknown[]): number { + const bytes = new TextEncoder().encode(JSON.stringify({ messages, tools })).byteLength; + return Math.ceil(bytes / 2); +} + /** * 保留最近 keepLastAssistantTurns 轮 assistant(带 toolCalls) 及其后的消息原文, * 将更早的 tool 角色消息内容替换为占位文本。 @@ -23,10 +29,25 @@ export function elideOldToolResults(messages: ChatRequest["messages"], keepLastA } } - for (let i = 0; i < cutoffIndex; i++) { + 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; } } } + +/** 在安全预算内尽量保留最近的 tool 结果;必要时裁剪全部旧结果。 */ +export function elideUntilWithinBudget( + messages: ChatRequest["messages"], + contextWindow: number, + tools?: unknown[], + budgetRatio = 0.6 +): void { + if (estimateRequestTokens(messages, tools) / contextWindow < budgetRatio) return; + for (let keep = 5; keep >= 0; keep--) { + elideOldToolResults(messages, keep); + if (estimateRequestTokens(messages, tools) / contextWindow < budgetRatio) return; + } +} diff --git a/src/app/service/agent/core/task_scheduler.test.ts b/src/app/service/agent/core/task_scheduler.test.ts index 0560a246b..3d6ee9ada 100644 --- a/src/app/service/agent/core/task_scheduler.test.ts +++ b/src/app/service/agent/core/task_scheduler.test.ts @@ -216,6 +216,7 @@ describe("AgentTaskScheduler", () => { internalExecutor.mockRejectedValue( Object.assign(new Error("超过最大迭代次数"), { usage: { inputTokens: 120, outputTokens: 40 }, + conversationId: "conv-failed", }) ); @@ -226,6 +227,7 @@ describe("AgentTaskScheduler", () => { 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 () => { diff --git a/src/app/service/agent/core/task_scheduler.ts b/src/app/service/agent/core/task_scheduler.ts index 2a774baf5..c110d4089 100644 --- a/src/app/service/agent/core/task_scheduler.ts +++ b/src/app/service/agent/core/task_scheduler.ts @@ -84,6 +84,7 @@ export class AgentTaskScheduler { 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/service_worker/background.test.ts b/src/app/service/agent/service_worker/background.test.ts index e65faab52..a51eff2a2 100644 --- a/src/app/service/agent/service_worker/background.test.ts +++ b/src/app/service/agent/service_worker/background.test.ts @@ -383,8 +383,8 @@ describe("handleAttachToConversation 重连逻辑", () => { 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); @@ -394,6 +394,8 @@ describe("handleAttachToConversation 重连逻辑", () => { expect(resolveCount).toBe(1); expect(lastAnswer).toBe("第一个"); + expect(sent1.some((message) => message.data?.type === "ask_user_resolved")).toBe(true); + expect(sent2.some((message) => message.data?.type === "ask_user_resolved")).toBe(true); (service as any).bgSessionManager.delete("conv-multi"); }); 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 ca3a03c43..1be37ffd7 100644 --- a/src/app/service/agent/service_worker/background_session_manager.ts +++ b/src/app/service/agent/service_worker/background_session_manager.ts @@ -199,7 +199,7 @@ export class BackgroundSessionManager { if (resolver) { rc.askResolvers.delete(msg.data.id); rc.pendingAskUser = undefined; - sendEvent({ type: "ask_user_resolved", id: msg.data.id }); + this.broadcastEvent(rc, { type: "ask_user_resolved", id: msg.data.id }); resolver(msg.data.answer); } } diff --git a/src/app/service/agent/service_worker/chat_service.ts b/src/app/service/agent/service_worker/chat_service.ts index e09af5c49..7d0638e50 100644 --- a/src/app/service/agent/service_worker/chat_service.ts +++ b/src/app/service/agent/service_worker/chat_service.ts @@ -38,7 +38,8 @@ 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 { t } from "@App/locales/locales"; -import { elideOldToolResults } from "@App/app/service/agent/core/context_elision"; +import { elideUntilWithinBudget } from "@App/app/service/agent/core/context_elision"; +import { getContextWindow } from "@App/app/service/agent/core/model_context"; import type { LLMCallResult } from "./llm_client"; /** ChatService 需要的 execute_script 工具依赖 */ @@ -203,6 +204,10 @@ export class ChatService { // 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); const finish = (answer: string) => { @@ -371,7 +376,7 @@ export class ChatService { conversationId: params.conversationId, rehydratedHistory: true, skipBuiltinTools: !enableTools, - askUserForGuard, + askUserForGuard: params.scriptUuid ? undefined : askUserForGuard, }); // 后台模式:正常完成后延迟清理 this.bgSessionManager.cleanupIfDone(params.conversationId); @@ -479,7 +484,7 @@ export class ChatService { summaryMessages.push({ role: "system", content: COMPACT_SYSTEM_PROMPT }); summaryMessages.push(...historyMessages); - elideOldToolResults(summaryMessages, 5); + elideUntilWithinBudget(summaryMessages, getContextWindow(model)); summaryMessages.push({ role: "user", content: buildCompactUserPrompt(params.compactInstruction) }); 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..a97af50be 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,29 @@ 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 })); + }); }); 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..5c478802e 100644 --- a/src/app/service/agent/service_worker/sub_agent_service.ts +++ b/src/app/service/agent/service_worker/sub_agent_service.ts @@ -171,6 +171,7 @@ export class SubAgentService { currentMsg = { content: "", toolCalls: [] }; break; case "done": + case "error": if (event.usage) { subUsage.inputTokens += event.usage.inputTokens; subUsage.outputTokens += event.usage.outputTokens; diff --git a/src/app/service/agent/service_worker/task_service.ts b/src/app/service/agent/service_worker/task_service.ts index 1e4db968f..2add2c207 100644 --- a/src/app/service/agent/service_worker/task_service.ts +++ b/src/app/service/agent/service_worker/task_service.ts @@ -163,6 +163,7 @@ export class AgentTaskService { throw Object.assign(new Error(event.message), { errorCode: event.errorCode, usage: totalUsage, + conversationId, }); } }; 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 cf045ce3a..2467d5d30 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator.ts @@ -14,7 +14,11 @@ import type { 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 } from "@App/app/service/agent/core/context_elision"; +import { + elideOldToolResults, + elideUntilWithinBudget, + estimateRequestTokens, +} from "@App/app/service/agent/core/context_elision"; import type { LLMCallResult } from "./llm_client"; import { t } from "@App/locales/locales"; @@ -66,7 +70,7 @@ function waitForGuardAnswer( signal.addEventListener("abort", onAbort, { once: true }); Promise.resolve() - .then(() => askUserForGuard(strikeCount)) + .then(() => (settled || signal.aborted ? null : askUserForGuard(strikeCount))) .then(resolveOnce, rejectOnce); }); } @@ -143,10 +147,11 @@ export class ToolLoopOrchestrator { // 持久化历史保留完整结果供 UI 展示;LLM 只使用独立副本,续接时首个请求也先裁剪旧结果。 const messages = rehydratedHistory ? inputMessages.map((message) => ({ ...message })) : inputMessages; if (rehydratedHistory) { - const estimatedInputTokens = JSON.stringify(messages).length / 4; + const initialTools = params.skipBuiltinTools ? tools || [] : toolRegistry.getDefinitions(tools); + const estimatedInputTokens = estimateRequestTokens(messages, initialTools); const estimatedUsageRatio = estimatedInputTokens / getContextWindow(model); if (estimatedUsageRatio >= ELISION_THRESHOLDS[0]) { - elideOldToolResults(messages, ELISION_KEEP_LAST_ASSISTANT_TURNS); + elideUntilWithinBudget(messages, getContextWindow(model), initialTools, ELISION_THRESHOLDS[0]); } } 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..4345b233a 100644 --- a/src/app/service/content/gm_api/cat_agent.test.ts +++ b/src/app/service/content/gm_api/cat_agent.test.ts @@ -380,7 +380,12 @@ 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 }, + }; const gmConnect = vi.fn().mockResolvedValue(mockConnectWithEvents([errorEvent])); const instance = new ConversationInstance( @@ -395,6 +400,7 @@ 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 }); }); it("error event 无 errorCode 时,errorCode 应为 undefined", async () => { @@ -442,7 +448,12 @@ 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 }, + }; const gmConnect = vi.fn().mockResolvedValue(mockConnectWithEvents([errorEvent])); const instance = new ConversationInstance( @@ -463,6 +474,7 @@ 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 }); }); it("error event 无 errorCode 时,chunk.errorCode 应为 undefined", async () => { diff --git a/src/app/service/content/gm_api/cat_agent.ts b/src/app/service/content/gm_api/cat_agent.ts index e0a484a51..a425f37d0 100644 --- a/src/app/service/content/gm_api/cat_agent.ts +++ b/src/app/service/content/gm_api/cat_agent.ts @@ -370,7 +370,7 @@ export class ConversationInstance { break; } case "error": - reject(Object.assign(new Error(event.message), { errorCode: event.errorCode })); + reject(Object.assign(new Error(event.message), { errorCode: event.errorCode, usage: event.usage })); break; } }); @@ -421,8 +421,8 @@ export class ConversationInstance { done = true; break; case "error": - chunk = { type: "error", error: event.message, errorCode: event.errorCode }; - error = Object.assign(new Error(event.message), { errorCode: event.errorCode }); + chunk = { type: "error", error: event.message, errorCode: event.errorCode, usage: event.usage }; + error = Object.assign(new Error(event.message), { errorCode: event.errorCode, usage: event.usage }); done = true; break; } diff --git a/src/pages/options/routes/Agent/Chat/ChatArea.tsx b/src/pages/options/routes/Agent/Chat/ChatArea.tsx index 82422c3a0..80b262fe3 100644 --- a/src/pages/options/routes/Agent/Chat/ChatArea.tsx +++ b/src/pages/options/routes/Agent/Chat/ChatArea.tsx @@ -228,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; @@ -237,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({ @@ -381,6 +380,8 @@ export default function ChatArea({ 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; From 256705d8b19b4422a147fc367340c3feb08cbed8 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:52:05 +0900 Subject: [PATCH 13/38] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20ask?= =?UTF-8?q?=5Fuser=20=E7=BB=88=E6=80=81=E4=B8=8E=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E4=BC=A0=E6=92=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/agent/core/context_elision.ts | 17 +++- src/app/service/agent/core/tool_registry.ts | 3 +- .../service/agent/core/tools/ask_user.test.ts | 15 +++ src/app/service/agent/core/tools/ask_user.ts | 28 ++++-- src/app/service/agent/core/types.ts | 2 + .../agent/service_worker/autocompact.test.ts | 4 +- .../service/agent/service_worker/chat.test.ts | 30 ++++++ .../agent/service_worker/chat_service.ts | 17 +++- .../agent/service_worker/sub_agent_service.ts | 2 + .../agent/service_worker/task_service.ts | 2 + .../service_worker/tool_loop_orchestrator.ts | 98 +++++++++++++++---- .../service/content/gm_api/cat_agent.test.ts | 4 + src/app/service/content/gm_api/cat_agent.ts | 22 ++++- 13 files changed, 200 insertions(+), 44 deletions(-) diff --git a/src/app/service/agent/core/context_elision.ts b/src/app/service/agent/core/context_elision.ts index 50cbfcf10..37012cac2 100644 --- a/src/app/service/agent/core/context_elision.ts +++ b/src/app/service/agent/core/context_elision.ts @@ -7,8 +7,12 @@ export const ELIDED_TOOL_RESULT_STUB = /** 以 UTF-8 字节数的保守上界估算 provider 请求 token。 */ export function estimateRequestTokens(messages: ChatRequest["messages"], tools?: unknown[]): number { + const hasUnresolvedAttachment = messages.some( + (message) => Array.isArray(message.content) && message.content.some((block) => block.type !== "text") + ); + if (hasUnresolvedAttachment) return Number.POSITIVE_INFINITY; const bytes = new TextEncoder().encode(JSON.stringify({ messages, tools })).byteLength; - return Math.ceil(bytes / 2); + return bytes; } /** @@ -43,11 +47,14 @@ export function elideUntilWithinBudget( messages: ChatRequest["messages"], contextWindow: number, tools?: unknown[], - budgetRatio = 0.6 -): void { - if (estimateRequestTokens(messages, tools) / contextWindow < budgetRatio) return; + budgetRatio = 0.9 +): boolean { + const hasToolResults = messages.some((message) => message.role === "tool"); + if (!hasToolResults) return estimateRequestTokens(messages, tools) / contextWindow < budgetRatio; + if (estimateRequestTokens(messages, tools) / contextWindow < budgetRatio) return true; for (let keep = 5; keep >= 0; keep--) { elideOldToolResults(messages, keep); - if (estimateRequestTokens(messages, tools) / contextWindow < budgetRatio) return; + if (estimateRequestTokens(messages, tools) / contextWindow < budgetRatio) return true; } + return false; } diff --git a/src/app/service/agent/core/tool_registry.ts b/src/app/service/agent/core/tool_registry.ts index 72ff81b15..41c6fddbe 100644 --- a/src/app/service/agent/core/tool_registry.ts +++ b/src/app/service/agent/core/tool_registry.ts @@ -30,6 +30,7 @@ export type ScriptToolCallback = (toolCalls: ToolCall[]) => Promise { 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/types.ts b/src/app/service/agent/core/types.ts index dd809b09d..0444dda68 100644 --- a/src/app/service/agent/core/types.ts +++ b/src/app/service/agent/core/types.ts @@ -270,6 +270,7 @@ export type ChatReply = { cacheCreationInputTokens?: number; cacheReadInputTokens?: number; }; + durationMs?: number; command?: boolean; // 标识该回复来自命令处理 }; @@ -285,6 +286,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/autocompact.test.ts b/src/app/service/agent/service_worker/autocompact.test.ts index a0ae36470..5905100a3 100644 --- a/src/app/service/agent/service_worker/autocompact.test.ts +++ b/src/app/service/agent/service_worker/autocompact.test.ts @@ -109,7 +109,7 @@ describe("Compact 功能", () => { apiBaseUrl: "", apiKey: "", model: "gpt-4o", - contextWindow: 1000, + contextWindow: 10000, }); mockRepo.listConversations.mockResolvedValue([BASE_CONV]); const messages: any[] = []; @@ -126,7 +126,7 @@ describe("Compact 功能", () => { id: `t${i}`, conversationId: "conv-1", role: "tool", - content: "工具结果".repeat(100), + content: "工具结果".repeat(500), toolCallId: `tc${i}`, createtime: i, }); diff --git a/src/app/service/agent/service_worker/chat.test.ts b/src/app/service/agent/service_worker/chat.test.ts index f37dd5a80..55d25a3ce 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 场景补充", () => { diff --git a/src/app/service/agent/service_worker/chat_service.ts b/src/app/service/agent/service_worker/chat_service.ts index 7d0638e50..20257a6bf 100644 --- a/src/app/service/agent/service_worker/chat_service.ts +++ b/src/app/service/agent/service_worker/chat_service.ts @@ -403,13 +403,15 @@ export class ChatService { content: "", error: errorMsg, errorCode, + usage: e.usage, + durationMs: e.durationMs, createtime: Date.now(), }); } catch { // 持久化失败不阻塞错误事件发送 } } - sendEvent({ type: "error", message: errorMsg, errorCode }); + sendEvent({ type: "error", message: errorMsg, errorCode, usage: e.usage, durationMs: e.durationMs }); this.bgSessionManager.cleanupIfDone(params.conversationId); } } @@ -484,10 +486,13 @@ export class ChatService { summaryMessages.push({ role: "system", content: COMPACT_SYSTEM_PROMPT }); summaryMessages.push(...historyMessages); - elideUntilWithinBudget(summaryMessages, getContextWindow(model)); - summaryMessages.push({ role: "user", content: buildCompactUserPrompt(params.compactInstruction) }); + if (!elideUntilWithinBudget(summaryMessages, getContextWindow(model))) { + sendEvent({ type: "error", message: "Conversation history is too large to compact" }); + return; + } + // 不带 tools 调用 LLM const result = await this.llmDeps.callLLM( model, @@ -562,8 +567,10 @@ export class ChatService { } // Ask user - const askTool = createAskUserTool(sendEvent, askResolvers); - sessionRegistry.register("session", askTool.definition, askTool.executor); + if (!params.scriptUuid) { + const askTool = createAskUserTool(sendEvent, askResolvers, abortController.signal); + sessionRegistry.register("session", askTool.definition, askTool.executor); + } // Sub-agent const subAgentTool = createSubAgentTool({ 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 5c478802e..5f52ff508 100644 --- a/src/app/service/agent/service_worker/sub_agent_service.ts +++ b/src/app/service/agent/service_worker/sub_agent_service.ts @@ -21,6 +21,7 @@ export interface SubAgentOrchestrator { scriptToolCallback: null; excludeTools?: string[]; cache?: boolean; + throwOnTerminalError?: boolean; }): Promise; } @@ -192,6 +193,7 @@ export class SubAgentService { scriptToolCallback: null, excludeTools: params.excludeTools, cache: false, + throwOnTerminalError: true, }); // 检查是否因超时中止(区分用户主动取消和超时) diff --git a/src/app/service/agent/service_worker/task_service.ts b/src/app/service/agent/service_worker/task_service.ts index 2add2c207..8d69bccdf 100644 --- a/src/app/service/agent/service_worker/task_service.ts +++ b/src/app/service/agent/service_worker/task_service.ts @@ -37,6 +37,7 @@ export interface TaskOrchestrator { scriptToolCallback: ScriptToolCallback | null; conversationId: string; rehydratedHistory?: boolean; + throwOnTerminalError?: boolean; }): Promise; } @@ -178,6 +179,7 @@ export class AgentTaskService { scriptToolCallback: null, conversationId, rehydratedHistory: Boolean(task.conversationId), + throwOnTerminalError: true, }); // 通知 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 2467d5d30..ef4fd1cad 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator.ts @@ -131,6 +131,8 @@ export class ToolLoopOrchestrator { // 循环检测连续命中达到 GUARD_ESCALATION_STRIKES 次时调用,暂停循环询问用户是否继续。 // 仅由 UI 对话(含后台会话)传入;定时任务、子代理不传,保持原有的仅告警不暂停行为。 askUserForGuard?: AskUserForGuard; + // 定时任务和子代理需要将 max_iterations 作为失败抛给调用方。 + throwOnTerminalError?: boolean; }): Promise { const { toolRegistry, @@ -143,7 +145,16 @@ export class ToolLoopOrchestrator { scriptToolCallback, conversationId, rehydratedHistory, + throwOnTerminalError, } = params; + const startTime = Date.now(); + const totalUsage = { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + }; + // 持久化历史保留完整结果供 UI 展示;LLM 只使用独立副本,续接时首个请求也先裁剪旧结果。 const messages = rehydratedHistory ? inputMessages.map((message) => ({ ...message })) : inputMessages; if (rehydratedHistory) { @@ -151,18 +162,42 @@ export class ToolLoopOrchestrator { const estimatedInputTokens = estimateRequestTokens(messages, initialTools); const estimatedUsageRatio = estimatedInputTokens / getContextWindow(model); if (estimatedUsageRatio >= ELISION_THRESHOLDS[0]) { - elideUntilWithinBudget(messages, getContextWindow(model), initialTools, ELISION_THRESHOLDS[0]); + const withinBudget = elideUntilWithinBudget(messages, getContextWindow(model), initialTools, 0.9); + const hasContinuationHistory = + messages.filter((message) => message.role === "user" || message.role === "assistant").length > 2; + if (!withinBudget && hasContinuationHistory) { + const error = Object.assign(new Error("Conversation history exceeds the model context window"), { + errorCode: "context_too_large", + usage: totalUsage, + durationMs: Date.now() - startTime, + conversationId, + }); + sendEvent({ + type: "error", + message: error.message, + errorCode: error.errorCode, + usage: error.usage, + durationMs: error.durationMs, + }); + if (conversationId) { + await this.chatRepo.appendMessage({ + id: uuidv4(), + conversationId, + role: "assistant", + content: "", + error: error.message, + errorCode: error.errorCode, + durationMs: error.durationMs, + createtime: Date.now(), + }); + } + if (throwOnTerminalError) throw error; + return; + } } } - const startTime = Date.now(); let iterations = 0; - const totalUsage = { - inputTokens: 0, - outputTokens: 0, - cacheCreationInputTokens: 0, - cacheReadInputTokens: 0, - }; const toolCallHistory: ToolCallRecord[] = []; let guardStartIndex = 0; // 已触发过的裁剪阈值下标(-1 表示尚未触发过),避免同一阈值区间内每轮重复裁剪 @@ -184,16 +219,25 @@ export class ToolLoopOrchestrator { } // 调用 LLM(重试由 llm_client 内部处理) - const result = await this.deps.callLLM( - model, - { - messages, - tools: allToolDefs.length > 0 ? allToolDefs : undefined, - cache: params.cache, - }, - sendEvent, - signal - ); + let result: LLMCallResult; + try { + result = await this.deps.callLLM( + model, + { + messages, + tools: allToolDefs.length > 0 ? allToolDefs : undefined, + cache: params.cache, + }, + sendEvent, + signal + ); + } catch (error) { + throw Object.assign(error instanceof Error ? error : new Error(String(error)), { + usage: totalUsage, + durationMs: Date.now() - startTime, + conversationId, + }); + } if (signal.aborted) return; @@ -268,6 +312,7 @@ export class ToolLoopOrchestrator { const attachmentUpdates = new Map(); const subAgentUpdates = new Map(); const completedIds = new Set(); + const failedIds = new Set(); for (const tr of toolResults) { // LLM 上下文只包含文本结果,不含附件 @@ -285,6 +330,7 @@ export class ToolLoopOrchestrator { }); completedIds.add(tr.id); + if (tr.error) failedIds.add(tr.id); if (tr.attachments?.length) { attachmentUpdates.set(tr.id, tr.attachments); } @@ -310,7 +356,8 @@ export class ToolLoopOrchestrator { // 必须在此回写为 "completed",否则刷新/重载会从库里读回 running,导致工具图标一直转圈。 const applyToolUpdates = (toolCalls: ToolCall[]) => { for (const tc of toolCalls) { - if (completedIds.has(tc.id)) tc.status = "completed"; + 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); @@ -447,12 +494,21 @@ export class ToolLoopOrchestrator { createtime: Date.now(), }); } - sendEvent({ + 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 4345b233a..faaabffa6 100644 --- a/src/app/service/content/gm_api/cat_agent.test.ts +++ b/src/app/service/content/gm_api/cat_agent.test.ts @@ -385,6 +385,7 @@ describe("errorCode 透传:chat()", () => { message: "Rate limit exceeded", errorCode: "rate_limit", usage: { inputTokens: 12, outputTokens: 3 }, + durationMs: 321, }; const gmConnect = vi.fn().mockResolvedValue(mockConnectWithEvents([errorEvent])); @@ -401,6 +402,7 @@ describe("errorCode 透传:chat()", () => { 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 () => { @@ -453,6 +455,7 @@ describe("errorCode 透传:chatStream()", () => { message: "Tool timed out", errorCode: "tool_timeout", usage: { inputTokens: 12, outputTokens: 3 }, + durationMs: 321, }; const gmConnect = vi.fn().mockResolvedValue(mockConnectWithEvents([errorEvent])); @@ -475,6 +478,7 @@ describe("errorCode 透传:chatStream()", () => { 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 () => { diff --git a/src/app/service/content/gm_api/cat_agent.ts b/src/app/service/content/gm_api/cat_agent.ts index a425f37d0..57b1699c0 100644 --- a/src/app/service/content/gm_api/cat_agent.ts +++ b/src/app/service/content/gm_api/cat_agent.ts @@ -370,7 +370,13 @@ export class ConversationInstance { break; } case "error": - reject(Object.assign(new Error(event.message), { errorCode: event.errorCode, usage: event.usage })); + reject( + Object.assign(new Error(event.message), { + errorCode: event.errorCode, + usage: event.usage, + durationMs: event.durationMs, + }) + ); break; } }); @@ -421,8 +427,18 @@ export class ConversationInstance { done = true; break; case "error": - chunk = { type: "error", error: event.message, errorCode: event.errorCode, usage: event.usage }; - error = Object.assign(new Error(event.message), { errorCode: event.errorCode, usage: event.usage }); + 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; } From cbb6d8fc10cac128d579de8da73dc6db88961eee Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 09:16:28 +0900 Subject: [PATCH 14/38] =?UTF-8?q?=F0=9F=90=9B=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=87=E4=B8=8E=E5=B7=A5=E5=85=B7=E7=BB=88?= =?UTF-8?q?=E6=80=81=E4=BC=A0=E6=92=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent/core/context_elision.test.ts | 13 ++++ src/app/service/agent/core/context_elision.ts | 78 ++++++++++++++++--- src/app/service/agent/core/tool_registry.ts | 17 +++- .../service/agent/core/tools/ask_user.test.ts | 1 + src/app/service/agent/core/types.ts | 8 +- .../background_session_manager.ts | 2 +- .../agent/service_worker/chat_service.ts | 7 +- .../agent/service_worker/compact_service.ts | 9 +++ .../service/agent/service_worker/llm.test.ts | 5 +- .../service_worker/sub_agent_service.test.ts | 29 +++++++ .../agent/service_worker/sub_agent_service.ts | 74 ++++++++++++------ .../agent/service_worker/task_service.ts | 7 -- .../tool_loop_orchestrator.test.ts | 15 ++++ .../service_worker/tool_loop_orchestrator.ts | 41 ++++++---- .../service/content/gm_api/cat_agent.test.ts | 12 ++- src/app/service/content/gm_api/cat_agent.ts | 13 ++-- .../options/routes/Agent/Chat/ChatArea.tsx | 4 +- 17 files changed, 260 insertions(+), 75 deletions(-) diff --git a/src/app/service/agent/core/context_elision.test.ts b/src/app/service/agent/core/context_elision.test.ts index 82cc7b686..2f93228f5 100644 --- a/src/app/service/agent/core/context_elision.test.ts +++ b/src/app/service/agent/core/context_elision.test.ts @@ -123,4 +123,17 @@ describe("上下文预算估算与裁剪", () => { .every((message) => message.content === ELIDED_TOOL_RESULT_STUB) ).toBe(true); }); + + it("按附件实际字节估算,并可只省略较旧的多模态块", () => { + const messages: ChatRequest["messages"] = [ + { role: "user", content: [{ type: "image", attachmentId: "old", mimeType: "image/png" }] }, + { role: "assistant", content: "已看到图片" }, + { role: "user", content: "继续" }, + ]; + const sizes = new Map([["old", 6000]]); + expect(estimateRequestTokens(messages, [], sizes)).toBeGreaterThan(6000); + expect(elideUntilWithinBudget(messages, 1000, [], 0.9, sizes)).toBe(true); + expect(messages[0].content).toEqual([{ type: "text", text: expect.stringContaining("attachment elided") }]); + expect(messages[2].content).toBe("继续"); + }); }); diff --git a/src/app/service/agent/core/context_elision.ts b/src/app/service/agent/core/context_elision.ts index 37012cac2..d69fbd5a1 100644 --- a/src/app/service/agent/core/context_elision.ts +++ b/src/app/service/agent/core/context_elision.ts @@ -5,14 +5,55 @@ import type { ChatRequest } from "./types"; export const ELIDED_TOOL_RESULT_STUB = "[tool result elided to save context — re-run the tool if you need this data again]"; -/** 以 UTF-8 字节数的保守上界估算 provider 请求 token。 */ -export function estimateRequestTokens(messages: ChatRequest["messages"], tools?: unknown[]): number { - const hasUnresolvedAttachment = messages.some( - (message) => Array.isArray(message.content) && message.content.some((block) => block.type !== "text") +export const ELIDED_ATTACHMENT_STUB = "[attachment elided to save context — 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 { + // 无法读取的附件保留为未知大小,由预算检查决定是否省略或拒绝请求。 + } + }) ); - if (hasUnresolvedAttachment) return Number.POSITIVE_INFINITY; + return sizes; +} + +/** 以 UTF-8 字节数的保守上界估算 provider 请求 token。 */ +export function estimateRequestTokens( + messages: ChatRequest["messages"], + tools?: unknown[], + attachmentSizes?: Map +): number { + const attachmentBytes = messages.reduce((sum, message) => { + if (!Array.isArray(message.content)) return sum; + return ( + sum + + message.content.reduce((blockSum, block) => { + if (block.type === "text") return blockSum; + const size = attachmentSizes?.get(block.attachmentId); + if (size == null) return Number.POSITIVE_INFINITY; + return blockSum + (block.type === "file" ? 0 : Math.ceil(size / 3) * 4 + 128); + }, 0) + ); + }, 0); + if (!Number.isFinite(attachmentBytes)) return Number.POSITIVE_INFINITY; const bytes = new TextEncoder().encode(JSON.stringify({ messages, tools })).byteLength; - return bytes; + return bytes + attachmentBytes; } /** @@ -42,19 +83,34 @@ export function elideOldToolResults(messages: ChatRequest["messages"], keepLastA } } +/** 将较旧消息中的多模态块替换为可恢复的文本占位,保留最近消息的附件。 */ +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: ELIDED_ATTACHMENT_STUB } + ); + } +} + /** 在安全预算内尽量保留最近的 tool 结果;必要时裁剪全部旧结果。 */ export function elideUntilWithinBudget( messages: ChatRequest["messages"], contextWindow: number, tools?: unknown[], - budgetRatio = 0.9 + budgetRatio = 0.9, + attachmentSizes?: Map ): boolean { const hasToolResults = messages.some((message) => message.role === "tool"); - if (!hasToolResults) return estimateRequestTokens(messages, tools) / contextWindow < budgetRatio; - if (estimateRequestTokens(messages, tools) / contextWindow < budgetRatio) return true; + const estimate = () => estimateRequestTokens(messages, tools, attachmentSizes); + if (!hasToolResults && estimate() / contextWindow < budgetRatio) return true; + if (hasToolResults && estimate() / contextWindow < budgetRatio) return true; for (let keep = 5; keep >= 0; keep--) { elideOldToolResults(messages, keep); - if (estimateRequestTokens(messages, tools) / contextWindow < budgetRatio) return true; + if (estimate() / contextWindow < budgetRatio) return true; } - return false; + elideOldAttachments(messages); + return estimate() / contextWindow < budgetRatio; } diff --git a/src/app/service/agent/core/tool_registry.ts b/src/app/service/agent/core/tool_registry.ts index 41c6fddbe..db4e8a891 100644 --- a/src/app/service/agent/core/tool_registry.ts +++ b/src/app/service/agent/core/tool_registry.ts @@ -24,7 +24,9 @@ export interface ToolEntry { } // 脚本工具回调类型:将 tool calls 发送到 Sandbox 执行 -export type ScriptToolCallback = (toolCalls: ToolCall[]) => Promise>; +export type ScriptToolCallback = ( + toolCalls: ToolCall[] +) => Promise>; // 工具执行结果(可能含附件和子代理详情) export type ToolExecuteResult = { @@ -195,6 +197,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; } @@ -227,7 +230,12 @@ export class ToolRegistry implements ToolExecutorLike { } } catch (e: any) { console.error(`[ToolRegistry] tool "${tc.name}" execution failed:`, e); - return { id: tc.id, result: JSON.stringify({ error: extractErrorMessage(e) }), error: true }; + return { + id: tc.id, + result: JSON.stringify({ error: extractErrorMessage(e) }), + error: true, + subAgentDetails: e.subAgentDetails, + }; } }) ); @@ -243,13 +251,13 @@ export class ToolRegistry implements ToolExecutorLike { 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 }); + results.push({ id: sr.id, result: parsed.content, attachments, error: sr.error }); continue; } } catch { // 不是 JSON 或不是结构化结果,按原始字符串处理 } - results.push({ id: sr.id, result: sr.result }); + results.push({ id: sr.id, result: sr.result, error: sr.error }); } } else { // 没有脚本回调,返回错误并列出可用工具名,引导 LLM 自我纠正 @@ -263,6 +271,7 @@ export class ToolRegistry implements ToolExecutorLike { result: JSON.stringify({ error: `Tool "${tc.name}" not found. Available tools: [${availableNames.join(", ")}].${hint}`, }), + error: true, }); } } 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 4a0cc5763..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 () => { diff --git a/src/app/service/agent/core/types.ts b/src/app/service/agent/core/types.ts index 0444dda68..8b7215871 100644 --- a/src/app/service/agent/core/types.ts +++ b/src/app/service/agent/core/types.ts @@ -120,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 }; 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 1be37ffd7..0663ad73c 100644 --- a/src/app/service/agent/service_worker/background_session_manager.ts +++ b/src/app/service/agent/service_worker/background_session_manager.ts @@ -96,7 +96,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; } diff --git a/src/app/service/agent/service_worker/chat_service.ts b/src/app/service/agent/service_worker/chat_service.ts index 20257a6bf..73f54c964 100644 --- a/src/app/service/agent/service_worker/chat_service.ts +++ b/src/app/service/agent/service_worker/chat_service.ts @@ -38,7 +38,7 @@ 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 { t } from "@App/locales/locales"; -import { elideUntilWithinBudget } from "@App/app/service/agent/core/context_elision"; +import { elideUntilWithinBudget, loadAttachmentSizes } from "@App/app/service/agent/core/context_elision"; import { getContextWindow } from "@App/app/service/agent/core/model_context"; import type { LLMCallResult } from "./llm_client"; @@ -214,6 +214,7 @@ export class ChatService { clearTimeout(timer); askResolvers.delete(askId); cleanup(); + sendEvent({ type: "ask_user_resolved", id: askId }); resolve(answer); }; const onAbort = () => finish("stop"); @@ -277,7 +278,6 @@ export class ChatService { if (resolver) { askResolvers.delete(msg.data.id); if (rc) rc.pendingAskUser = undefined; - sendEvent({ type: "ask_user_resolved", id: msg.data.id }); resolver(msg.data.answer); } } @@ -488,7 +488,8 @@ export class ChatService { summaryMessages.push(...historyMessages); summaryMessages.push({ role: "user", content: buildCompactUserPrompt(params.compactInstruction) }); - if (!elideUntilWithinBudget(summaryMessages, getContextWindow(model))) { + const attachmentSizes = await loadAttachmentSizes(summaryMessages, (id) => this.chatRepo.getAttachment(id)); + if (!elideUntilWithinBudget(summaryMessages, getContextWindow(model), undefined, 0.9, attachmentSizes)) { sendEvent({ type: "error", message: "Conversation history is too large to compact" }); return; } diff --git a/src/app/service/agent/service_worker/compact_service.ts b/src/app/service/agent/service_worker/compact_service.ts index b09a47362..b97147083 100644 --- a/src/app/service/agent/service_worker/compact_service.ts +++ b/src/app/service/agent/service_worker/compact_service.ts @@ -14,6 +14,8 @@ import { } from "@App/app/service/agent/core/compact_prompt"; import { uuidv4 } from "@App/pkg/utils/uuid"; import type { AgentModelService } from "./model_service"; +import { elideUntilWithinBudget, loadAttachmentSizes } from "@App/app/service/agent/core/context_elision"; +import { getContextWindow } from "@App/app/service/agent/core/model_context"; /** LLM 调用结果(与 AgentService.callLLM 返回值一致) */ interface CompactLLMResult { @@ -64,6 +66,13 @@ export class CompactService { } summaryMessages.push({ role: "user", content: buildCompactUserPrompt() }); + const attachmentSizes = await loadAttachmentSizes(summaryMessages, (id) => this.chatRepo.getAttachment(id)); + if (!elideUntilWithinBudget(summaryMessages, getContextWindow(model), undefined, 0.9, attachmentSizes)) { + 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( diff --git a/src/app/service/agent/service_worker/llm.test.ts b/src/app/service/agent/service_worker/llm.test.ts index ccc06c6f4..2b17c89ec 100644 --- a/src/app/service/agent/service_worker/llm.test.ts +++ b/src/app/service/agent/service_worker/llm.test.ts @@ -596,7 +596,7 @@ describe("callLLMWithToolLoop 工具调用循环", () => { apiBaseUrl: "", apiKey: "", model: "gpt-4o", - contextWindow: 1000, + contextWindow: 100000, }); // 使用两个不同名称的工具交替调用,避免触发 tool_call_guard 的重复调用检测 @@ -621,7 +621,7 @@ describe("callLLMWithToolLoop 工具调用循环", () => { // 前 4 轮占用较低;第 5 轮跨过 0.4 阈值(此时恰好 5 轮,保留窗口内不裁剪); // 第 6 轮跨过 0.6 阈值,触发第二次裁剪,第 1 轮此时已超出保留窗口 - const usages = [100, 100, 100, 100, 500, 700]; + 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 的“相同参数重复调用”检测 @@ -787,6 +787,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"); 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 a97af50be..5f433af39 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 @@ -88,4 +88,33 @@ describe("SubAgentService", () => { 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 }, + }, + }); + }); }); 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 5f52ff508..bfbb23d88 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"; @@ -67,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, @@ -183,18 +196,29 @@ 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, - throwOnTerminalError: true, - }); + 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); + 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 8d69bccdf..362e07ce2 100644 --- a/src/app/service/agent/service_worker/task_service.ts +++ b/src/app/service/agent/service_worker/task_service.ts @@ -160,13 +160,6 @@ export class AgentTaskService { totalUsage.inputTokens += event.usage.inputTokens; totalUsage.outputTokens += event.usage.outputTokens; } - if (event.type === "error") { - throw Object.assign(new Error(event.message), { - errorCode: event.errorCode, - usage: totalUsage, - conversationId, - }); - } }; await this.orchestrator.callLLMWithToolLoop({ 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 index ebe98a2fe..2c6920482 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator.test.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator.test.ts @@ -89,6 +89,21 @@ describe("ToolLoopOrchestrator 循环检测升级(loop-guard escalation)", ( 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( + baseParams({ model: { ...MODEL, contextWindow: 10000 }, messages: [{ role: "user", content: "开始" }] }) + ) + ).rejects.toMatchObject({ + message: "compact failed", + conversationId: "conv-1", + usage: { inputTokens: 9000, outputTokens: 5 }, + }); + }); + it("续接长历史时,首个 LLM 请求使用裁剪后的副本且不修改原始历史", async () => { const oldToolResult = "完整工具结果".repeat(100); const messages: ChatRequest["messages"] = []; 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 ef4fd1cad..204bf847b 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator.ts @@ -18,6 +18,7 @@ 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"; @@ -154,31 +155,29 @@ export class ToolLoopOrchestrator { cacheCreationInputTokens: 0, cacheReadInputTokens: 0, }; + const attachmentSizes = await loadAttachmentSizes(inputMessages, (id) => this.chatRepo.getAttachment(id)); // 持久化历史保留完整结果供 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); + const estimatedInputTokens = estimateRequestTokens(messages, initialTools, attachmentSizes); const estimatedUsageRatio = estimatedInputTokens / getContextWindow(model); if (estimatedUsageRatio >= ELISION_THRESHOLDS[0]) { - const withinBudget = elideUntilWithinBudget(messages, getContextWindow(model), initialTools, 0.9); - const hasContinuationHistory = - messages.filter((message) => message.role === "user" || message.role === "assistant").length > 2; - if (!withinBudget && hasContinuationHistory) { + const withinBudget = elideUntilWithinBudget( + messages, + getContextWindow(model), + initialTools, + 0.9, + attachmentSizes + ); + if (!withinBudget) { const error = Object.assign(new Error("Conversation history exceeds the model context window"), { errorCode: "context_too_large", usage: totalUsage, durationMs: Date.now() - startTime, conversationId, }); - sendEvent({ - type: "error", - message: error.message, - errorCode: error.errorCode, - usage: error.usage, - durationMs: error.durationMs, - }); if (conversationId) { await this.chatRepo.appendMessage({ id: uuidv4(), @@ -191,6 +190,13 @@ export class ToolLoopOrchestrator { createtime: Date.now(), }); } + sendEvent({ + type: "error", + message: error.message, + errorCode: error.errorCode, + usage: error.usage, + durationMs: error.durationMs, + }); if (throwOnTerminalError) throw error; return; } @@ -257,7 +263,15 @@ export class ToolLoopOrchestrator { contextUsageRatio = getContextInputTokens(model, result.usage) / contextWindow; if (contextUsageRatio >= 0.8) { - await this.deps.autoCompact(conversationId, model, messages, sendEvent, signal); + try { + await this.deps.autoCompact(conversationId, model, messages, sendEvent, signal); + } catch (error) { + throw Object.assign(error instanceof Error ? error : new Error(String(error)), { + usage: totalUsage, + durationMs: Date.now() - startTime, + conversationId, + }); + } } else { for (let i = 0; i < ELISION_THRESHOLDS.length; i++) { if (i > lastElisionThresholdIndex && contextUsageRatio >= ELISION_THRESHOLDS[i]) { @@ -326,6 +340,7 @@ export class ToolLoopOrchestrator { type: "tool_call_complete", id: tr.id, result: tr.result, + status: tr.error ? "error" : "completed", attachments: tr.attachments, }); 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 faaabffa6..202c76b4d 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() {}, @@ -83,6 +83,7 @@ describe("ConversationInstance 命令机制", () => { expect(result.command).toBeUndefined(); expect(result.content).toBe("LLM reply"); + expect(result.durationMs).toBe(123); // 应该建立了 LLM 连接 expect(gmConnect).toHaveBeenCalled(); }); @@ -116,6 +117,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({ diff --git a/src/app/service/content/gm_api/cat_agent.ts b/src/app/service/content/gm_api/cat_agent.ts index 57b1699c0..a6fceece3 100644 --- a/src/app/service/content/gm_api/cat_agent.ts +++ b/src/app/service/content/gm_api/cat_agent.ts @@ -316,6 +316,7 @@ export class ConversationInstance { const contentBlocks: ContentBlock[] = []; let currentToolCall: ToolCall | null = null; let usage: { inputTokens: number; outputTokens: number } | undefined; + let durationMs: number | undefined; conn.onMessage(async (msg: any) => { if (msg.action === "executeTools") { @@ -353,6 +354,7 @@ export class ConversationInstance { currentToolCall = null; } if (event.usage) usage = event.usage; + durationMs = event.durationMs; // 合并文本和 content blocks 到 MessageContent let finalContent: MessageContent = content; if (contentBlocks.length > 0) { @@ -366,6 +368,7 @@ export class ConversationInstance { thinking: thinking || undefined, toolCalls: toolCalls.length > 0 ? toolCalls : undefined, usage, + durationMs, }); break; } @@ -423,7 +426,7 @@ export class ConversationInstance { chunk = { type: "tool_call", toolCall: { ...event.toolCall, arguments: "" } }; break; case "done": - chunk = { type: "done", usage: event.usage }; + chunk = { type: "done", usage: event.usage, durationMs: event.durationMs }; done = true; break; case "error": @@ -527,13 +530,13 @@ export class ConversationInstance { private async executeTools( toolCalls: ToolCall[], handlers: Map) => Promise> - ): Promise> { - const results: Array<{ id: string; result: string }> = []; + ): 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` }) }); + results.push({ id: tc.id, result: JSON.stringify({ error: `Tool "${tc.name}" not found` }), error: true }); continue; } @@ -551,7 +554,7 @@ export class ConversationInstance { : typeof e === "string" ? e : String(e) || "Tool execution failed"; - results.push({ id: tc.id, result: JSON.stringify({ error: errorMsg }) }); + results.push({ id: tc.id, result: JSON.stringify({ error: errorMsg }), error: true }); } } diff --git a/src/pages/options/routes/Agent/Chat/ChatArea.tsx b/src/pages/options/routes/Agent/Chat/ChatArea.tsx index 80b262fe3..b7d9e59c1 100644 --- a/src/pages/options/routes/Agent/Chat/ChatArea.tsx +++ b/src/pages/options/routes/Agent/Chat/ChatArea.tsx @@ -206,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; } @@ -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; } From b335f5c4cc5c30a8be2c4a7ccb14f1e8bb881ab7 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:17:08 +0900 Subject: [PATCH 15/38] =?UTF-8?q?=F0=9F=90=9B=20=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E5=89=8D=E9=A2=84=E7=AE=97=E6=A3=80=E6=9F=A5=EF=BC=8C=E9=98=B2?= =?UTF-8?q?=E6=AD=A2=20tool=20=E7=BB=93=E6=9E=9C=E6=92=91=E7=88=86?= =?UTF-8?q?=E4=B8=8B=E4=B8=80=E6=AC=A1=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 每轮 LLM 调用前用当前 messages + 工具定义重新估算体积,超出安全阈值时立即裁剪; 不再仅依赖上一轮响应的 usage 反馈,避免巨大 tool 结果在下一次请求发出前就把上下文撑爆。 Co-Authored-By: Claude Sonnet 5 --- .../tool_loop_orchestrator.test.ts | 60 ++++++++++++ .../service_worker/tool_loop_orchestrator.ts | 96 +++++++++++++------ 2 files changed, 127 insertions(+), 29 deletions(-) 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 index 2c6920482..7a38af742 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator.test.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator.test.ts @@ -214,3 +214,63 @@ describe("ToolLoopOrchestrator 循环检测升级(loop-guard escalation)", ( 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, + model: { ...MODEL, contextWindow: 1000 }, + 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); + }); +}); 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 204bf847b..2c777e80c 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator.ts @@ -26,6 +26,8 @@ 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; @@ -107,6 +109,47 @@ export class ToolLoopOrchestrator { private chatRepo: AgentChatRepo ) {} + /** 上下文即使裁剪到底也无法容纳下一次请求时,落库 + 通知 UI + (可选)抛出结构化错误。 */ + private async emitContextTooLarge( + conversationId: string | undefined, + totalUsage: { + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens: number; + cacheReadInputTokens: number; + }, + startTime: number, + sendEvent: (event: ChatStreamEvent) => void, + throwOnTerminalError?: boolean + ): Promise { + 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) { + await this.chatRepo.appendMessage({ + id: uuidv4(), + conversationId, + role: "assistant", + content: "", + error: error.message, + errorCode: error.errorCode, + durationMs: error.durationMs, + createtime: Date.now(), + }); + } + sendEvent({ + type: "error", + message: error.message, + errorCode: error.errorCode, + usage: error.usage, + durationMs: error.durationMs, + }); + if (throwOnTerminalError) throw error; + } + // 统一的 tool calling 循环,UI 和脚本共用 async callLLMWithToolLoop(params: { // 本次调用使用的工具注册表(SessionToolRegistry 或 ToolRegistry) @@ -161,7 +204,7 @@ export class ToolLoopOrchestrator { const messages = rehydratedHistory ? inputMessages.map((message) => ({ ...message })) : inputMessages; if (rehydratedHistory) { const initialTools = params.skipBuiltinTools ? tools || [] : toolRegistry.getDefinitions(tools); - const estimatedInputTokens = estimateRequestTokens(messages, initialTools, attachmentSizes); + const estimatedInputTokens = estimateRequestTokens(messages, initialTools, attachmentSizes, model); const estimatedUsageRatio = estimatedInputTokens / getContextWindow(model); if (estimatedUsageRatio >= ELISION_THRESHOLDS[0]) { const withinBudget = elideUntilWithinBudget( @@ -169,35 +212,11 @@ export class ToolLoopOrchestrator { getContextWindow(model), initialTools, 0.9, - attachmentSizes + attachmentSizes, + model ); if (!withinBudget) { - 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) { - await this.chatRepo.appendMessage({ - id: uuidv4(), - conversationId, - role: "assistant", - content: "", - error: error.message, - errorCode: error.errorCode, - durationMs: error.durationMs, - createtime: Date.now(), - }); - } - sendEvent({ - type: "error", - message: error.message, - errorCode: error.errorCode, - usage: error.usage, - durationMs: error.durationMs, - }); - if (throwOnTerminalError) throw error; + await this.emitContextTooLarge(conversationId, totalUsage, startTime, sendEvent, throwOnTerminalError); return; } } @@ -224,6 +243,26 @@ export class ToolLoopOrchestrator { allToolDefs = allToolDefs.filter((t) => !excludeSet.has(t.name)); } + // 发送前预算检查:用上一轮真实 usage 判断是否需要裁剪只在响应之后生效, + // 无法覆盖“上一轮新追加的 tool 结果单独撑爆下一次请求”的情况,必须在这里对 + // 即将发出的完整请求(messages + 当前工具定义)做一次独立估算。 + const contextWindow = getContextWindow(model); + const preflightTokens = estimateRequestTokens(messages, allToolDefs, attachmentSizes, model); + if (preflightTokens / contextWindow >= PREFLIGHT_BUDGET_RATIO) { + const withinBudget = elideUntilWithinBudget( + messages, + contextWindow, + allToolDefs, + PREFLIGHT_BUDGET_RATIO, + attachmentSizes, + model + ); + if (!withinBudget) { + await this.emitContextTooLarge(conversationId, totalUsage, startTime, sendEvent, throwOnTerminalError); + return; + } + } + // 调用 LLM(重试由 llm_client 内部处理) let result: LLMCallResult; try { @@ -259,7 +298,6 @@ export class ToolLoopOrchestrator { let pendingElision = false; let contextUsageRatio: number | null = null; if (result.usage && conversationId) { - const contextWindow = getContextWindow(model); contextUsageRatio = getContextInputTokens(model, result.usage) / contextWindow; if (contextUsageRatio >= 0.8) { From b17e1395ff326605f3d64f34db1395335f356cbd Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:17:23 +0900 Subject: [PATCH 16/38] =?UTF-8?q?=F0=9F=90=9B=20=E9=99=84=E4=BB=B6?= =?UTF-8?q?=E9=A2=84=E7=AE=97=E6=8C=89=20provider=20=E5=AE=9E=E9=99=85?= =?UTF-8?q?=E5=86=85=E8=81=94=E8=A1=8C=E4=B8=BA=E4=BC=B0=E7=AE=97=EF=BC=8C?= =?UTF-8?q?=E8=A3=81=E5=89=AA=E4=BF=9D=E7=95=99=E5=8F=AF=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E6=A0=87=E8=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit file/audio 从不被 provider 内联,image 只在 vision 模型上才会解析为 data URL; 按类型和模型能力估算体积,避免非 vision 场景或无法读取的附件把预算估算撑到 Infinity。 裁剪后的占位文本保留 type/attachmentId(OPFS 路径),而非完全丢弃可恢复信息。 Co-Authored-By: Claude Sonnet 5 --- .../agent/core/context_elision.test.ts | 55 ++++++++++++- src/app/service/agent/core/context_elision.ts | 30 +++++-- .../agent/service_worker/chat_service.ts | 81 +++++++++++++++---- .../agent/service_worker/compact_service.ts | 2 +- 4 files changed, 141 insertions(+), 27 deletions(-) diff --git a/src/app/service/agent/core/context_elision.test.ts b/src/app/service/agent/core/context_elision.test.ts index 2f93228f5..cd861ac83 100644 --- a/src/app/service/agent/core/context_elision.test.ts +++ b/src/app/service/agent/core/context_elision.test.ts @@ -5,7 +5,25 @@ import { elideUntilWithinBudget, estimateRequestTokens, } from "./context_elision"; -import type { ChatRequest } from "./types"; +import type { AgentModelConfig, ChatRequest } 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"] { @@ -124,16 +142,45 @@ describe("上下文预算估算与裁剪", () => { ).toBe(true); }); - it("按附件实际字节估算,并可只省略较旧的多模态块", () => { + it("vision 模型下按图片附件实际字节估算,并可只省略较旧的多模态块", () => { const messages: ChatRequest["messages"] = [ { role: "user", content: [{ type: "image", attachmentId: "old", mimeType: "image/png" }] }, { role: "assistant", content: "已看到图片" }, { role: "user", content: "继续" }, ]; const sizes = new Map([["old", 6000]]); - expect(estimateRequestTokens(messages, [], sizes)).toBeGreaterThan(6000); - expect(elideUntilWithinBudget(messages, 1000, [], 0.9, sizes)).toBe(true); + expect(estimateRequestTokens(messages, [], sizes, VISION_MODEL)).toBeGreaterThan(6000); + 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 模型下不解析图片,不应因缺失大小把预算估算撑爆", () => { + 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); + }); }); diff --git a/src/app/service/agent/core/context_elision.ts b/src/app/service/agent/core/context_elision.ts index d69fbd5a1..37319502f 100644 --- a/src/app/service/agent/core/context_elision.ts +++ b/src/app/service/agent/core/context_elision.ts @@ -1,11 +1,15 @@ // 滑动窗口裁剪:仅裁剪内存中传给 LLM 的 messages(不影响 chatRepo 持久化/UI 历史), // 用于在触及 autoCompact 的 80% 阈值之前,减少长 tool loop 中旧 tool 结果的重复计费。 -import type { ChatRequest } from "./types"; +import type { AgentModelConfig, ChatRequest, ContentBlock } from "./types"; +import { supportsVision } from "./model_capabilities"; export const ELIDED_TOOL_RESULT_STUB = "[tool result elided to save context — re-run the tool if you need this data again]"; -export const ELIDED_ATTACHMENT_STUB = "[attachment elided to save context — re-open the attachment if needed]"; +/** 附件裁剪占位文本:保留 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( @@ -33,21 +37,30 @@ export async function loadAttachmentSizes( return sizes; } -/** 以 UTF-8 字节数的保守上界估算 provider 请求 token。 */ +/** + * 以 UTF-8 字节数的保守上界估算 provider 请求 token。 + * 只对 provider 实际会内联展开为 base64 的块计入二进制字节: + * 当前仅 vision 模型的 image 块会被 resolveAttachments 加载;file/audio 及非 vision 模型的 + * image 均降级为纯文本描述(见 providers/content_utils.ts),其体积已包含在下方的 JSON 基线字节里。 + */ export function estimateRequestTokens( messages: ChatRequest["messages"], tools?: unknown[], - attachmentSizes?: Map + attachmentSizes?: Map, + model?: AgentModelConfig ): number { + const hasVision = model ? supportsVision(model) : false; const attachmentBytes = 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 Number.POSITIVE_INFINITY; - return blockSum + (block.type === "file" ? 0 : Math.ceil(size / 3) * 4 + 128); + return blockSum + (Math.ceil(size / 3) * 4 + 128); }, 0) ); }, 0); @@ -90,7 +103,7 @@ export function elideOldAttachments(messages: ChatRequest["messages"], keepLastM const message = messages[i]; if (!Array.isArray(message.content)) continue; message.content = message.content.map((block) => - block.type === "text" ? block : { type: "text", text: ELIDED_ATTACHMENT_STUB } + block.type === "text" ? block : { type: "text", text: elidedAttachmentStub(block) } ); } } @@ -101,10 +114,11 @@ export function elideUntilWithinBudget( contextWindow: number, tools?: unknown[], budgetRatio = 0.9, - attachmentSizes?: Map + attachmentSizes?: Map, + model?: AgentModelConfig ): boolean { const hasToolResults = messages.some((message) => message.role === "tool"); - const estimate = () => estimateRequestTokens(messages, tools, attachmentSizes); + const estimate = () => estimateRequestTokens(messages, tools, attachmentSizes, model); if (!hasToolResults && estimate() / contextWindow < budgetRatio) return true; if (hasToolResults && estimate() / contextWindow < budgetRatio) return true; for (let keep = 5; keep >= 0; keep--) { diff --git a/src/app/service/agent/service_worker/chat_service.ts b/src/app/service/agent/service_worker/chat_service.ts index 73f54c964..123fd2bf7 100644 --- a/src/app/service/agent/service_worker/chat_service.ts +++ b/src/app/service/agent/service_worker/chat_service.ts @@ -210,18 +210,22 @@ export class ChatService { } const askId = `guard_${uuidv4()}`; const cleanup = () => abortController.signal.removeEventListener("abort", onAbort); - const finish = (answer: string) => { + // settle 只负责清理与 resolve,不发送任何终态事件; + // 终态事件(resolved / expired)由每个触发路径各自发送且只发一次,避免重复广播 + const settle = (answer: string) => { clearTimeout(timer); askResolvers.delete(askId); cleanup(); - sendEvent({ type: "ask_user_resolved", id: askId }); resolve(answer); }; - const onAbort = () => finish("stop"); + const onAbort = () => { + sendEvent({ type: "ask_user_expired", id: askId }); + settle("stop"); + }; const timer = setTimeout( () => { sendEvent({ type: "ask_user_expired", id: askId }); - finish("continue"); + settle("continue"); }, 5 * 60 * 1000 ); @@ -236,11 +240,32 @@ export class ChatService { }); abortController.signal.addEventListener("abort", onAbort, { once: true }); askResolvers.set(askId, (answer: string) => { - finish(answer); + 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 = { @@ -254,8 +279,10 @@ export class ChatService { msgConn.onDisconnect(() => { isDisconnected = true; - // 后台模式:只移除 listener,不 abort + // 后台模式:只移除 listener,不 abort 整条会话; + // 但这条连接对应的脚本工具调用必须结束,否则永远等不到 toolResults rc!.listeners.delete(listener); + settlePendingScriptCall("Script connection disconnected"); }); } else { msgConn.onDisconnect(() => { @@ -264,14 +291,16 @@ export class ChatService { }); } - // 构建脚本工具回调:通过 MessageConnect 让 Sandbox 执行 handler - let toolResultResolve: ((results: Array<{ id: string; result: string }>) => void) | null = null; + // abort(stop / 非后台断开)时结束等待中的脚本工具调用,避免循环卡死 + abortController.signal.addEventListener("abort", () => { + settlePendingScriptCall("Tool execution aborted"); + }); msgConn.onMessage((msg: any) => { - if (msg.action === "toolResults" && toolResultResolve) { - const resolve = toolResultResolve; - toolResultResolve = null; - resolve(msg.data); + if (msg.action === "toolResults" && pendingScriptCall) { + const { settle } = pendingScriptCall; + pendingScriptCall = null; + settle(msg.data); } if (msg.action === "askUserResponse" && msg.data) { const resolver = askResolvers.get(msg.data.id); @@ -286,10 +315,34 @@ export class ChatService { } }); + // 脚本工具单次调用的最长等待时间:Sandbox 长时间无响应(如脚本卡死)时, + // 主动结束这轮 tool call 而不是无限期挂起整条对话 + const SCRIPT_TOOL_TIMEOUT_MS = 5 * 60 * 1000; + const scriptToolCallback: ScriptToolCallback = (toolCalls: ToolCall[]) => { return new Promise((resolve) => { - toolResultResolve = 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")); + } }); }; @@ -489,7 +542,7 @@ export class ChatService { summaryMessages.push({ role: "user", content: buildCompactUserPrompt(params.compactInstruction) }); const attachmentSizes = await loadAttachmentSizes(summaryMessages, (id) => this.chatRepo.getAttachment(id)); - if (!elideUntilWithinBudget(summaryMessages, getContextWindow(model), undefined, 0.9, attachmentSizes)) { + if (!elideUntilWithinBudget(summaryMessages, getContextWindow(model), undefined, 0.9, attachmentSizes, model)) { sendEvent({ type: "error", message: "Conversation history is too large to compact" }); return; } diff --git a/src/app/service/agent/service_worker/compact_service.ts b/src/app/service/agent/service_worker/compact_service.ts index b97147083..901fce418 100644 --- a/src/app/service/agent/service_worker/compact_service.ts +++ b/src/app/service/agent/service_worker/compact_service.ts @@ -67,7 +67,7 @@ export class CompactService { summaryMessages.push({ role: "user", content: buildCompactUserPrompt() }); const attachmentSizes = await loadAttachmentSizes(summaryMessages, (id) => this.chatRepo.getAttachment(id)); - if (!elideUntilWithinBudget(summaryMessages, getContextWindow(model), undefined, 0.9, attachmentSizes)) { + if (!elideUntilWithinBudget(summaryMessages, getContextWindow(model), undefined, 0.9, attachmentSizes, model)) { throw Object.assign(new Error("Conversation history is too large to compact"), { errorCode: "context_too_large", }); From 8c72058a591b665f59e40162b00d7865840b4161 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:17:37 +0900 Subject: [PATCH 17/38] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E8=84=9A=E6=9C=AC=E5=B7=A5=E5=85=B7=E5=BE=AA?= =?UTF-8?q?=E7=8E=AF=E9=81=97=E6=BC=8F=20tool=5Fcall=5Fcomplete/new=5Fmess?= =?UTF-8?q?age=20=E4=BA=8B=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 非流式与流式适配器都只处理了 tool_call_start/delta,从未处理 tool_call_complete 和 new_message,导致 ephemeral 历史中的 toolCalls 没有结果/终态,且多轮对话被压平成一条 消息、最终回复重复追加。按轮次边界重建 assistant + 对应 tool 消息,并在 StreamChunk 新增 tool_call_complete/new_message 类型透传给用户脚本。 Co-Authored-By: Claude Sonnet 5 --- src/app/service/agent/core/types.ts | 10 +- .../service/content/gm_api/cat_agent.test.ts | 115 +++++++++++++++ src/app/service/content/gm_api/cat_agent.ts | 135 ++++++++++++++---- src/types/scriptcat.d.ts | 16 ++- src/types/scriptcat.zh-CN.d.ts | 16 ++- 5 files changed, 261 insertions(+), 31 deletions(-) diff --git a/src/app/service/agent/core/types.ts b/src/app/service/agent/core/types.ts index 8b7215871..99f27f9ff 100644 --- a/src/app/service/agent/core/types.ts +++ b/src/app/service/agent/core/types.ts @@ -282,7 +282,15 @@ export type ChatReply = { // 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; 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 202c76b4d..f212d994d 100644 --- a/src/app/service/content/gm_api/cat_agent.test.ts +++ b/src/app/service/content/gm_api/cat_agent.test.ts @@ -367,6 +367,121 @@ 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); + }); +}); + // ---- errorCode 透传测试 ---- // 创建发送指定事件序列的 mock 连接 diff --git a/src/app/service/content/gm_api/cat_agent.ts b/src/app/service/content/gm_api/cat_agent.ts index a6fceece3..25c2398c3 100644 --- a/src/app/service/content/gm_api/cat_agent.ts +++ b/src/app/service/content/gm_api/cat_agent.ts @@ -127,16 +127,9 @@ export class ConversationInstance { const reply = await this.processChat(conn, handlers); - // ephemeral 模式:收集 assistant 响应到内存历史 + // ephemeral 模式:中间轮次(带 tool calls)已在 processChat 内按 new_message 边界追加到内存历史, + // 这里只需追加不含 tool calls 的最终回复(done 事件保证到达时已无待处理的 tool calls)。 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 }); } @@ -312,12 +305,35 @@ export class ConversationInstance { return new Promise((resolve, reject) => { let content = ""; let thinking = ""; - const toolCalls: ToolCall[] = []; + 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 @@ -348,6 +364,19 @@ export class ConversationInstance { 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); @@ -399,6 +428,8 @@ export class ConversationInstance { 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") { @@ -422,8 +453,44 @@ export class ConversationInstance { case "content_block_complete": chunk = { type: "content_block", block: event.block }; break; - case "tool_call_start": - chunk = { type: "tool_call", toolCall: { ...event.toolCall, arguments: "" } }; + 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 }; @@ -488,7 +555,32 @@ export class ConversationInstance { const inner = this.processStream(conn, handlers); const messageHistory = this.messageHistory; let content = ""; - const toolCalls: ToolCall[] = []; + 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]() { @@ -497,14 +589,7 @@ export class ConversationInstance { 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, - }); - } + finishRound(); return result; } @@ -514,9 +599,11 @@ export class ConversationInstance { content += chunk.content || ""; break; case "tool_call": - if (chunk.toolCall) { - toolCalls.push(chunk.toolCall); - } + case "tool_call_complete": + if (chunk.toolCall) upsertToolCall(chunk.toolCall); + break; + case "new_message": + finishRound(); break; } return result; diff --git a/src/types/scriptcat.d.ts b/src/types/scriptcat.d.ts index 85a45b9d0..66e65ced2 100644 --- a/src/types/scriptcat.d.ts +++ b/src/types/scriptcat.d.ts @@ -941,17 +941,27 @@ 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 }; diff --git a/src/types/scriptcat.zh-CN.d.ts b/src/types/scriptcat.zh-CN.d.ts index 4c99999cb..15e02e3de 100644 --- a/src/types/scriptcat.zh-CN.d.ts +++ b/src/types/scriptcat.zh-CN.d.ts @@ -948,17 +948,27 @@ 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 }; From 7df958589341723a1473b9a04e73a4a0d4d01d88 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:18:17 +0900 Subject: [PATCH 18/38] =?UTF-8?q?=F0=9F=90=9B=20=E5=AD=90=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E5=8E=9F=E7=94=9F=E5=BC=82=E5=B8=B8=E8=A1=A5=E5=8F=91?= =?UTF-8?q?=E7=BB=88=E6=80=81=E4=BA=8B=E4=BB=B6=EF=BC=8Ctool=5Fcall=5Fcomp?= =?UTF-8?q?lete=20=E7=8A=B6=E6=80=81=E4=B8=8D=E5=86=8D=E7=A1=AC=E7=BC=96?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit orchestrator 的 callLLM/autoCompact 原生失败只 throw、不 sendEvent,导致实时 UI 收不到 子代理的终态事件、卡在 running 直到刷新;在捕获异常时补发一次 error 事件(若尚未上报过 终态)。同时 tool_call_complete 记录状态改为 event.status ?? "completed",避免持久化后 把失败的嵌套工具显示为成功。 Co-Authored-By: Claude Sonnet 5 --- .../service/agent/service_worker/chat.test.ts | 132 ++++++++++++++++++ .../service_worker/sub_agent_service.test.ts | 77 ++++++++++ .../agent/service_worker/sub_agent_service.ts | 18 ++- 3 files changed, 226 insertions(+), 1 deletion(-) diff --git a/src/app/service/agent/service_worker/chat.test.ts b/src/app/service/agent/service_worker/chat.test.ts index 55d25a3ce..fac7edb24 100644 --- a/src/app/service/agent/service_worker/chat.test.ts +++ b/src/app/service/agent/service_worker/chat.test.ts @@ -538,3 +538,135 @@ 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)}}}]}}]}\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 }; + }, + }; + }, + }, + } 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("非后台会话断开(触发 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(); + } + }); +}); 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 5f433af39..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 @@ -117,4 +117,81 @@ describe("SubAgentService", () => { }, }); }); + + 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 bfbb23d88..1be82f2d2 100644 --- a/src/app/service/agent/service_worker/sub_agent_service.ts +++ b/src/app/service/agent/service_worker/sub_agent_service.ts @@ -132,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) => { // 转发事件给父代理 @@ -170,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; } @@ -186,6 +189,7 @@ export class SubAgentService { break; case "done": case "error": + terminalEventEmitted = true; if (event.usage) { subUsage.inputTokens += event.usage.inputTokens; subUsage.outputTokens += event.usage.outputTokens; @@ -216,6 +220,18 @@ export class SubAgentService { } 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; } From fc587b26fac74ef77ef5cdec605663860e66f923 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:18:44 +0900 Subject: [PATCH 19/38] =?UTF-8?q?=F0=9F=90=9B=20=E6=B6=88=E9=99=A4=20ask?= =?UTF-8?q?=5Fuser=20=E7=BB=88=E6=80=81=E4=BA=8B=E4=BB=B6=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E5=B9=BF=E6=92=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后台会话 attach 收到 askUserResponse 后自己广播一次 ask_user_resolved,resolver (ask_user.ts / 循环检测 loop-guard)自己又广播一次,同一次回答产生两条终态事件; loop-guard 超时路径也是先发 ask_user_expired 又紧接着触发 ask_user_resolved。 改为终态事件只由 resolver 在恰好一处发出,attach 不再重复广播,超时/abort 只发 expired,只有真实回答才发 resolved。 Co-Authored-By: Claude Sonnet 5 --- .../agent/service_worker/background.test.ts | 18 ++++++++++++------ .../background_session_manager.ts | 3 ++- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/app/service/agent/service_worker/background.test.ts b/src/app/service/agent/service_worker/background.test.ts index a51eff2a2..164cce020 100644 --- a/src/app/service/agent/service_worker/background.test.ts +++ b/src/app/service/agent/service_worker/background.test.ts @@ -344,14 +344,16 @@ 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); @@ -365,12 +367,13 @@ describe("handleAttachToConversation 重连逻辑", () => { expect(resolvedAnswer).toBe("红色"); expect(rc.pendingAskUser).toBeUndefined(); expect(rc.askResolvers.has("ask-1")).toBe(false); - expect(sentMessages.some((message) => message.data?.type === "ask_user_resolved")).toBe(true); + 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" }); @@ -379,6 +382,7 @@ 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); @@ -394,8 +398,8 @@ describe("handleAttachToConversation 重连逻辑", () => { expect(resolveCount).toBe(1); expect(lastAnswer).toBe("第一个"); - expect(sent1.some((message) => message.data?.type === "ask_user_resolved")).toBe(true); - expect(sent2.some((message) => message.data?.type === "ask_user_resolved")).toBe(true); + 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"); }); @@ -681,6 +685,8 @@ describe("后台运行会话 集成测试", () => { 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 { 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 0663ad73c..94437aeab 100644 --- a/src/app/service/agent/service_worker/background_session_manager.ts +++ b/src/app/service/agent/service_worker/background_session_manager.ts @@ -199,7 +199,8 @@ export class BackgroundSessionManager { if (resolver) { rc.askResolvers.delete(msg.data.id); rc.pendingAskUser = undefined; - this.broadcastEvent(rc, { type: "ask_user_resolved", id: msg.data.id }); + // resolver 自身(ask_user.ts / askUserForGuard)负责广播其终态事件, + // 这里不再重复广播,否则同一次回答会产生两条 ask_user_resolved resolver(msg.data.answer); } } From dc5606457d4426de2d6d3014933dcb0ebc9e02f5 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:25:44 +0900 Subject: [PATCH 20/38] fix(agent): harden userscript tool sessions --- packages/message/types.ts | 2 + src/app/service/agent/core/model_context.ts | 25 + .../agent/service_worker/chat_service.ts | 833 ++----------- .../agent/service_worker/chat_service_base.ts | 780 ++++++++++++ .../service_worker/tool_loop_orchestrator.ts | 589 +--------- .../tool_loop_orchestrator_base.ts | 567 +++++++++ src/app/service/content/gm_api/cat_agent.ts | 1045 ++++++----------- .../service/content/gm_api/cat_agent_base.ts | 730 ++++++++++++ src/types/scriptcat.agent-background.d.ts | 41 + 9 files changed, 2635 insertions(+), 1977 deletions(-) create mode 100644 src/app/service/agent/service_worker/chat_service_base.ts create mode 100644 src/app/service/agent/service_worker/tool_loop_orchestrator_base.ts create mode 100644 src/app/service/content/gm_api/cat_agent_base.ts create mode 100644 src/types/scriptcat.agent-background.d.ts 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/service/agent/core/model_context.ts b/src/app/service/agent/core/model_context.ts index 46ea4e748..f29dddf67 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,6 +43,8 @@ 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 { @@ -52,6 +56,27 @@ export function getContextWindow(config: { model: string; contextWindow?: number return DEFAULT_CONTEXT_WINDOW; } +/** 获取 provider 实际会请求的最大输出 token 数。 */ +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 : config.provider === "anthropic" ? DEFAULT_ANTHROPIC_MAX_TOKENS : 0; + 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(); diff --git a/src/app/service/agent/service_worker/chat_service.ts b/src/app/service/agent/service_worker/chat_service.ts index 123fd2bf7..1d117bb09 100644 --- a/src/app/service/agent/service_worker/chat_service.ts +++ b/src/app/service/agent/service_worker/chat_service.ts @@ -1,780 +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 { 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 type { MessageConnect } from "@Packages/message/types"; import { uuidv4 } from "@App/pkg/utils/uuid"; -import { t } from "@App/locales/locales"; -import { elideUntilWithinBudget, loadAttachmentSizes } from "@App/app/service/agent/core/context_elision"; -import { getContextWindow } 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) => 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": - 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, - }); - 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 }); - } - } - }; - - // 循环检测(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"); +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, + })), }; - 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); - }); + for (const handler of inboundHandlers) handler(response); }); }; - // 等待中的脚本工具调用: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 }); + 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 整条会话; - // 但这条连接对应的脚本工具调用必须结束,否则永远等不到 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; - pendingScriptCall = null; - 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); + callback(message); + }); + }, + sendMessage(message: any) { + if (message.action !== "executeTools") { + if (!disconnected) original.sendMessage(message); + return; } - } - if (msg.action === "stop") { - 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")); + const correlated = { ...message, requestId: uuidv4() }; + activeRequestId = correlated.requestId; + if (disconnected) { + failBatch(correlated, "Script tool client is unavailable"); + return; } - }); - }; - - 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); - } - - // 构建消息列表并持久化用户消息 - 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, - }); - // 后台模式:正常完成后延迟清理 - 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"; - 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 { - // 持久化失败不阻塞错误事件发送 + 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, 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)); - if (!elideUntilWithinBudget(summaryMessages, getContextWindow(model), undefined, 0.9, attachmentSizes, model)) { - sendEvent({ type: "error", message: "Conversation history is too large to compact" }); - return; - } - - // 不带 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(), + }, + disconnect(ignoreAlreadyDisconnected?: boolean) { + original.disconnect(ignoreAlreadyDisconnected); + }, + onDisconnect(callback) { + original.onDisconnect((isSelfDisconnected) => { + disconnected = true; + callback(isSelfDisconnected); + }); + }, }; - 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 - 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 }> - ): 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;跳过错误占位消息 —— 如超过 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 }; + 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..123fd2bf7 --- /dev/null +++ b/src/app/service/agent/service_worker/chat_service_base.ts @@ -0,0 +1,780 @@ +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 { t } from "@App/locales/locales"; +import { elideUntilWithinBudget, loadAttachmentSizes } from "@App/app/service/agent/core/context_elision"; +import { getContextWindow } 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) => 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": + 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, + }); + 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 }); + } + } + }; + + // 循环检测(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; + pendingScriptCall = null; + 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") { + 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); + } + + // 构建消息列表并持久化用户消息 + 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, + }); + // 后台模式:正常完成后延迟清理 + 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"; + 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)); + if (!elideUntilWithinBudget(summaryMessages, getContextWindow(model), undefined, 0.9, attachmentSizes, model)) { + sendEvent({ type: "error", message: "Conversation history is too large to compact" }); + return; + } + + // 不带 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 + 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 }> + ): 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, + }); + 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/tool_loop_orchestrator.ts b/src/app/service/agent/service_worker/tool_loop_orchestrator.ts index 2c777e80c..43c96947d 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator.ts @@ -1,567 +1,56 @@ 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 { - elideOldToolResults, - elideUntilWithinBudget, - estimateRequestTokens, - loadAttachmentSizes, -} from "@App/app/service/agent/core/context_elision"; -import type { LLMCallResult } from "./llm_client"; -import { t } from "@App/locales/locales"; +import type { AgentModelConfig } from "@App/app/service/agent/core/types"; +import { getContextWindow, getInputTokenBudget } from "@App/app/service/agent/core/model_context"; +import { ToolLoopOrchestrator as BaseToolLoopOrchestrator, type ToolLoopDeps } from "./tool_loop_orchestrator_base"; -// 上下文占用达到这些比例时,分批裁剪保留窗口外的旧 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 type { ToolLoopDeps } from "./tool_loop_orchestrator_base"; +/** 在原编排器外层统一预留输出 token,并补齐终态上下文错误的 usage 持久化。 */ export class ToolLoopOrchestrator { - // 注意:不在构造器持有 toolRegistry。 - // 每次 callLLMWithToolLoop 由调用方传入(通常是 SessionToolRegistry), - // 保证并发会话各自使用独立的工具注册表,避免闭包互相覆盖。 constructor( private deps: ToolLoopDeps, private chatRepo: AgentChatRepo ) {} - /** 上下文即使裁剪到底也无法容纳下一次请求时,落库 + 通知 UI + (可选)抛出结构化错误。 */ - private async emitContextTooLarge( - conversationId: string | undefined, - totalUsage: { - inputTokens: number; - outputTokens: number; - cacheCreationInputTokens: number; - cacheReadInputTokens: number; - }, - startTime: number, - sendEvent: (event: ChatStreamEvent) => void, - throwOnTerminalError?: boolean - ): Promise { - 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) { - await this.chatRepo.appendMessage({ - id: uuidv4(), - conversationId, - role: "assistant", - content: "", - error: error.message, - errorCode: error.errorCode, - durationMs: error.durationMs, - createtime: Date.now(), - }); - } - sendEvent({ - type: "error", - message: error.message, - errorCode: error.errorCode, - usage: error.usage, - durationMs: error.durationMs, - }); - if (throwOnTerminalError) throw error; - } - - // 统一的 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; - // 消息来自持久化历史时,先在独立副本上裁剪旧 tool 结果。 - rehydratedHistory?: boolean; - // 循环检测连续命中达到 GUARD_ESCALATION_STRIKES 次时调用,暂停循环询问用户是否继续。 - // 仅由 UI 对话(含后台会话)传入;定时任务、子代理不传,保持原有的仅告警不暂停行为。 - askUserForGuard?: AskUserForGuard; - // 定时任务和子代理需要将 max_iterations 作为失败抛给调用方。 - throwOnTerminalError?: boolean; - }): Promise { - const { - toolRegistry, - model, - messages: inputMessages, - tools, - maxIterations, - sendEvent, - signal, - scriptToolCallback, - conversationId, - rehydratedHistory, - throwOnTerminalError, - } = params; - const startTime = Date.now(); + async callLLMWithToolLoop(params: Parameters[0]) { + const actualWindow = getContextWindow(params.model); + const inputBudget = getInputTokenBudget(params.model); + const effectiveWindow = Math.max(1, Math.floor(inputBudget / 0.9)); + const model: AgentModelConfig = { + ...params.model, + contextWindow: Math.min(actualWindow, effectiveWindow), + }; const totalUsage = { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, }; - const attachmentSizes = await loadAttachmentSizes(inputMessages, (id) => this.chatRepo.getAttachment(id)); - - // 持久化历史保留完整结果供 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 / getContextWindow(model); - if (estimatedUsageRatio >= ELISION_THRESHOLDS[0]) { - const withinBudget = elideUntilWithinBudget( - messages, - getContextWindow(model), - initialTools, - 0.9, - attachmentSizes, - model - ); - if (!withinBudget) { - await this.emitContextTooLarge(conversationId, totalUsage, startTime, sendEvent, 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 contextWindow = getContextWindow(model); - const preflightTokens = estimateRequestTokens(messages, allToolDefs, attachmentSizes, model); - if (preflightTokens / contextWindow >= PREFLIGHT_BUDGET_RATIO) { - const withinBudget = elideUntilWithinBudget( - messages, - contextWindow, - allToolDefs, - PREFLIGHT_BUDGET_RATIO, - attachmentSizes, - model - ); - if (!withinBudget) { - await this.emitContextTooLarge(conversationId, totalUsage, startTime, sendEvent, 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) { - throw Object.assign(error instanceof Error ? error : new Error(String(error)), { - usage: totalUsage, - durationMs: Date.now() - startTime, - conversationId, - }); - } - - 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% 时触发;否则按阈值分批裁剪旧 tool 结果(见下方 pendingElision) - let pendingElision = false; - let contextUsageRatio: number | null = null; - if (result.usage && conversationId) { - contextUsageRatio = getContextInputTokens(model, result.usage) / contextWindow; - - if (contextUsageRatio >= 0.8) { - try { - await this.deps.autoCompact(conversationId, model, messages, sendEvent, signal); - } catch (error) { - throw Object.assign(error instanceof Error ? error : new Error(String(error)), { - usage: totalUsage, - durationMs: Date.now() - startTime, - conversationId, - }); - } - } 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) - 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(); - 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 消息 - 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 }); - guardStrikeCount++; - - // 连续命中达到阈值时暂停,询问用户是否继续(仅 UI 对话传入 askUserForGuard 时生效) - if (guardStrikeCount >= GUARD_ESCALATION_STRIKES && params.askUserForGuard) { - const answer = await waitForGuardAnswer(params.askUserForGuard, guardStrikeCount, signal); - if (answer === null) { - const durationMs = Date.now() - startTime; - sendEvent({ type: "done", usage: totalUsage, durationMs }); - return; - } - if (answer.trim().toLowerCase() === GUARD_STOP_ANSWER.toLowerCase()) { - const durationMs = Date.now() - startTime; - if (conversationId) { - await this.chatRepo.appendMessage({ - id: uuidv4(), - conversationId, - role: "assistant", - content: t("agent:chat_guard_stopped_message"), - usage: totalUsage, - durationMs, - createtime: Date.now(), - }); - } - 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,对话结束 - 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 durationMs = Date.now() - startTime; - const maxIterMsg = `Tool calling loop exceeded maximum iterations (${maxIterations})`; - if (conversationId) { - await this.chatRepo.appendMessage({ - id: uuidv4(), - conversationId, - role: "assistant", - content: "", - error: maxIterMsg, - errorCode: "max_iterations", - usage: totalUsage, - durationMs, - createtime: Date.now(), - }); - } - 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, - }); - } + const deps: ToolLoopDeps = { + ...this.deps, + callLLM: async (...args) => { + const result = await this.deps.callLLM(...args); + 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; + } + return result; + }, + }; + const repo = new Proxy(this.chatRepo, { + get: (target, property, receiver) => { + if (property !== "appendMessage") return Reflect.get(target, property, receiver); + return (message: any) => + target.appendMessage( + message.errorCode === "context_too_large" && !message.usage + ? { ...message, usage: { ...totalUsage } } + : message + ); + }, + }); + const orchestrator = new BaseToolLoopOrchestrator(deps, repo); + return orchestrator.callLLMWithToolLoop({ ...params, model }); } } 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..2c777e80c --- /dev/null +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator_base.ts @@ -0,0 +1,567 @@ +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 { + 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 + (可选)抛出结构化错误。 */ + private async emitContextTooLarge( + conversationId: string | undefined, + totalUsage: { + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens: number; + cacheReadInputTokens: number; + }, + startTime: number, + sendEvent: (event: ChatStreamEvent) => void, + throwOnTerminalError?: boolean + ): Promise { + 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) { + await this.chatRepo.appendMessage({ + id: uuidv4(), + conversationId, + role: "assistant", + content: "", + error: error.message, + errorCode: error.errorCode, + durationMs: error.durationMs, + createtime: Date.now(), + }); + } + sendEvent({ + type: "error", + message: error.message, + errorCode: error.errorCode, + usage: error.usage, + durationMs: error.durationMs, + }); + if (throwOnTerminalError) throw error; + } + + // 统一的 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; + // 消息来自持久化历史时,先在独立副本上裁剪旧 tool 结果。 + rehydratedHistory?: boolean; + // 循环检测连续命中达到 GUARD_ESCALATION_STRIKES 次时调用,暂停循环询问用户是否继续。 + // 仅由 UI 对话(含后台会话)传入;定时任务、子代理不传,保持原有的仅告警不暂停行为。 + askUserForGuard?: AskUserForGuard; + // 定时任务和子代理需要将 max_iterations 作为失败抛给调用方。 + throwOnTerminalError?: boolean; + }): Promise { + const { + toolRegistry, + model, + 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)); + + // 持久化历史保留完整结果供 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 / getContextWindow(model); + if (estimatedUsageRatio >= ELISION_THRESHOLDS[0]) { + const withinBudget = elideUntilWithinBudget( + messages, + getContextWindow(model), + initialTools, + 0.9, + attachmentSizes, + model + ); + if (!withinBudget) { + await this.emitContextTooLarge(conversationId, totalUsage, startTime, sendEvent, 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 contextWindow = getContextWindow(model); + const preflightTokens = estimateRequestTokens(messages, allToolDefs, attachmentSizes, model); + if (preflightTokens / contextWindow >= PREFLIGHT_BUDGET_RATIO) { + const withinBudget = elideUntilWithinBudget( + messages, + contextWindow, + allToolDefs, + PREFLIGHT_BUDGET_RATIO, + attachmentSizes, + model + ); + if (!withinBudget) { + await this.emitContextTooLarge(conversationId, totalUsage, startTime, sendEvent, 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) { + throw Object.assign(error instanceof Error ? error : new Error(String(error)), { + usage: totalUsage, + durationMs: Date.now() - startTime, + conversationId, + }); + } + + 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% 时触发;否则按阈值分批裁剪旧 tool 结果(见下方 pendingElision) + let pendingElision = false; + let contextUsageRatio: number | null = null; + if (result.usage && conversationId) { + contextUsageRatio = getContextInputTokens(model, result.usage) / contextWindow; + + if (contextUsageRatio >= 0.8) { + try { + await this.deps.autoCompact(conversationId, model, messages, sendEvent, signal); + } catch (error) { + throw Object.assign(error instanceof Error ? error : new Error(String(error)), { + usage: totalUsage, + durationMs: Date.now() - startTime, + conversationId, + }); + } + } 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) + 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(); + 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 消息 + 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 }); + guardStrikeCount++; + + // 连续命中达到阈值时暂停,询问用户是否继续(仅 UI 对话传入 askUserForGuard 时生效) + if (guardStrikeCount >= GUARD_ESCALATION_STRIKES && params.askUserForGuard) { + const answer = await waitForGuardAnswer(params.askUserForGuard, guardStrikeCount, signal); + if (answer === null) { + const durationMs = Date.now() - startTime; + sendEvent({ type: "done", usage: totalUsage, durationMs }); + return; + } + if (answer.trim().toLowerCase() === GUARD_STOP_ANSWER.toLowerCase()) { + const durationMs = Date.now() - startTime; + if (conversationId) { + await this.chatRepo.appendMessage({ + id: uuidv4(), + conversationId, + role: "assistant", + content: t("agent:chat_guard_stopped_message"), + usage: totalUsage, + durationMs, + createtime: Date.now(), + }); + } + 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,对话结束 + 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 durationMs = Date.now() - startTime; + const maxIterMsg = `Tool calling loop exceeded maximum iterations (${maxIterations})`; + if (conversationId) { + await this.chatRepo.appendMessage({ + id: uuidv4(), + conversationId, + role: "assistant", + content: "", + error: maxIterMsg, + errorCode: "max_iterations", + usage: totalUsage, + durationMs, + createtime: Date.now(), + }); + } + 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.ts b/src/app/service/content/gm_api/cat_agent.ts index 25c2398c3..6d4f5dd45 100644 --- a/src/app/service/content/gm_api/cat_agent.ts +++ b/src/app/service/content/gm_api/cat_agent.ts @@ -1,730 +1,447 @@ -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 模式:中间轮次(带 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 }; - }, - }; - }, +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); - } - - // 解析命令:"/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; +type ToolHandler = (args: Record) => Promise; +type Instance = BaseConversationInstance & Record; - 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 }; - } +function cloneToolCall(toolCall: ToolCall): ToolCall { + return { + ...toolCall, + attachments: toolCall.attachments ? [...toolCall.attachments] : undefined, + }; +} - // 合并实例级别和调用级别的工具定义 - private mergeTools(callTools?: ChatOptions["tools"]) { - const toolDefs: ToolDefinition[] = [...this.toolDefs]; - const handlers = new Map(this.toolHandlers); +function stringifyToolResult(result: unknown): string { + if (typeof result === "string") return result; + const serialized = JSON.stringify(result); + return serialized === undefined ? "null" : serialized; +} - 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); - } - } +function buildContent(text: string, blocks: ContentBlock[]): MessageContent { + if (blocks.length === 0) return text; + return [...(text ? [{ type: "text" as const, text }] : []), ...blocks]; +} - return { toolDefs, handlers }; +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; +} - // 获取对话历史 - 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(), - })); +async function executeTools( + this: Instance, + toolCalls: ToolCall[], + handlers: Map +): Promise> { + const results: Array<{ id: string; result: string; error?: boolean }> = []; + for (const toolCall of toolCalls) { + 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 = ""; - 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, +function processChat(this: Instance, conn: MessageConnect, handlers: Map): Promise { + return new Promise((resolve, reject) => { + 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, - usage: event.usage, - durationMs: event.durationMs, - }) - ); - 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; - // 按 id 跟踪进行中的 tool call,供 tool_call_delta / tool_call_complete 原地更新 - const toolCallsById = new Map(); + } + 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") { + conn.sendMessage({ + action: "toolResults", + requestId: message.requestId, + data: await executeTools.call(this, message.data, handlers), + }); 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; 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 tc: ToolCall = { ...event.toolCall, arguments: event.toolCall.arguments || "" }; - toolCallsById.set(tc.id, tc); - chunk = { type: "tool_call", toolCall: { ...tc } }; + 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 tc = toolCallsById.get(event.id); - if (tc) { - tc.arguments += event.delta; - chunk = { type: "tool_call", toolCall: { ...tc } }; - } + const toolCall = resolveToolCall(ordered, byId, event.id, event.index); + if (toolCall) toolCall.arguments += event.delta; 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, - }, - }; + const toolCall = resolveToolCall(ordered, byId, event.id); + if (toolCall) + Object.assign(toolCall, event, { + status: event.status ?? "completed", + }); break; } case "new_message": - chunk = { type: "new_message" }; + finishRound(); break; case "done": - chunk = { type: "done", usage: event.usage, durationMs: event.durationMs }; - done = true; + usage = event.usage; + durationMs = event.durationMs; + resolve({ + content: finishRound(false), + thinking: thinking || undefined, + toolCalls: aggregate.length ? aggregate : undefined, + usage, + durationMs, + }); 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; + reject(Object.assign(new Error(event.message), event)); break; } - - if (chunk) { - chunks.push(chunk); - resolve?.(); - } - }); - - conn.onDisconnect(() => { - done = true; - error = error || new Error("Connection disconnected"); - resolve?.(); }); + conn.onDisconnect(() => 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 = ""; - 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, +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") { + conn.sendMessage({ + action: "toolResults", + requestId: message.requestId, + data: await executeTools.call(this, message.data, handlers), + }); + return; + } + if (message.action !== "event") return; + const event: ChatStreamEvent = message.data; + switch (event.type) { + case "sync": + reset(event.streamingMessage?.toolCalls || []); + push({ + type: "sync", + streamingMessage: event.streamingMessage + ? { + ...event.streamingMessage, + toolCalls: ordered.map(cloneToolCall), + } + : undefined, + pendingAskUser: event.pendingAskUser ? { ...event.pendingAskUser } : undefined, + tasks: event.tasks.map((task) => ({ ...task })), + status: event.status, }); - 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; - }, + done = event.status !== "running"; + 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; 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; + 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 }), error: true }); + break; + } + case "tool_call_complete": { + const toolCall = resolveToolCall(ordered, byId, event.id); + if (toolCall) + Object.assign(toolCall, event, { + status: event.status ?? "completed", + }); + push({ + type: "tool_call_complete", + toolCall: + toolCall || + ({ + id: event.id, + name: "", + arguments: "", + result: event.result, + status: event.status, + } 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; + 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; + break; } - - 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 - ); + }); + conn.onDisconnect(() => { + if (done) return; + done = true; + error = new Error("Connection disconnected"); + wake?.(); + }); + + 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 }; + }, + }; + }, + }; } -// 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) { + this.messageHistory.push({ + role: "assistant", + content: buildContent(text, blocks), + toolCalls: toolCalls.length ? toolCalls.map(cloneToolCall) : undefined, + }); + for (const toolCall of toolCalls) { + if (toolCall.result !== undefined) { + 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; + }, + }; + }, + }; +} - 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/types/scriptcat.agent-background.d.ts b/src/types/scriptcat.agent-background.d.ts new file mode 100644 index 000000000..55a321a3d --- /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>; + } +} From 700a848e735a08288dd30ff56c71a033c2237e72 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 13:19:27 +0900 Subject: [PATCH 21/38] =?UTF-8?q?=E8=84=9A=E6=9C=AC=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E7=BB=93=E6=9E=9C=E6=97=B6=EF=BC=8C=E5=BA=94?= =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E5=B7=A5=E5=85=B7=E5=BE=AA=E7=8E=AF=E5=B9=B6?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E5=AF=B9=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/agent/service_worker/chat.test.ts | 32 +++++++++++++++++++ .../agent/service_worker/chat_service_base.ts | 1 - 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/app/service/agent/service_worker/chat.test.ts b/src/app/service/agent/service_worker/chat.test.ts index fac7edb24..1a26713cd 100644 --- a/src/app/service/agent/service_worker/chat.test.ts +++ b/src/app/service/agent/service_worker/chat.test.ts @@ -610,6 +610,38 @@ describe("scriptToolCallback 的 abort/disconnect/超时处理", () => { 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); diff --git a/src/app/service/agent/service_worker/chat_service_base.ts b/src/app/service/agent/service_worker/chat_service_base.ts index 123fd2bf7..31428e543 100644 --- a/src/app/service/agent/service_worker/chat_service_base.ts +++ b/src/app/service/agent/service_worker/chat_service_base.ts @@ -299,7 +299,6 @@ export class ChatService { msgConn.onMessage((msg: any) => { if (msg.action === "toolResults" && pendingScriptCall) { const { settle } = pendingScriptCall; - pendingScriptCall = null; settle(msg.data); } if (msg.action === "askUserResponse" && msg.data) { From 381b33d5a46b1a7787bbd9321c7f0a3dfde9cbb6 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 13:59:21 +0900 Subject: [PATCH 22/38] =?UTF-8?q?=F0=9F=90=9B=20fix=20sub-agent=20streamin?= =?UTF-8?q?g=20and=20compact=20budgets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent/service_worker/chat_service_base.ts | 12 +- .../service_worker/compact_service.test.ts | 55 +++++++ .../agent/service_worker/compact_service.ts | 6 +- .../service/content/gm_api/cat_agent.test.ts | 136 +++++++++++++++++- src/app/service/content/gm_api/cat_agent.ts | 23 +-- src/types/scriptcat.agent-background.d.ts | 2 +- src/types/scriptcat.d.ts | 50 ++++++- src/types/scriptcat.zh-CN.d.ts | 51 ++++++- 8 files changed, 313 insertions(+), 22 deletions(-) create mode 100644 src/app/service/agent/service_worker/compact_service.test.ts diff --git a/src/app/service/agent/service_worker/chat_service_base.ts b/src/app/service/agent/service_worker/chat_service_base.ts index 31428e543..ebbbe091b 100644 --- a/src/app/service/agent/service_worker/chat_service_base.ts +++ b/src/app/service/agent/service_worker/chat_service_base.ts @@ -39,7 +39,7 @@ import { toLLMMessages } from "@App/app/service/agent/core/persisted_messages"; import { uuidv4 } from "@App/pkg/utils/uuid"; import { t } from "@App/locales/locales"; import { elideUntilWithinBudget, loadAttachmentSizes } from "@App/app/service/agent/core/context_elision"; -import { getContextWindow } from "@App/app/service/agent/core/model_context"; +import { getInputTokenBudget } from "@App/app/service/agent/core/model_context"; import type { LLMCallResult } from "./llm_client"; /** ChatService 需要的 execute_script 工具依赖 */ @@ -541,8 +541,14 @@ export class ChatService { summaryMessages.push({ role: "user", content: buildCompactUserPrompt(params.compactInstruction) }); const attachmentSizes = await loadAttachmentSizes(summaryMessages, (id) => this.chatRepo.getAttachment(id)); - if (!elideUntilWithinBudget(summaryMessages, getContextWindow(model), undefined, 0.9, attachmentSizes, model)) { - sendEvent({ type: "error", message: "Conversation history is too large to compact" }); + 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; } 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..8d005fa87 --- /dev/null +++ b/src/app/service/agent/service_worker/compact_service.test.ts @@ -0,0 +1,55 @@ +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, +}; + +function makeCurrentMessages(): ChatRequest["messages"] { + for (let len = 1; len < 20_000; len++) { + const currentMessages: ChatRequest["messages"] = [{ role: "user", content: "x".repeat(len) }]; + const summaryMessages: ChatRequest["messages"] = [ + { role: "system", content: COMPACT_SYSTEM_PROMPT }, + ...currentMessages, + { role: "user", content: buildCompactUserPrompt() }, + ]; + const estimate = estimateRequestTokens(summaryMessages, undefined, undefined, MODEL); + if (estimate > getInputTokenBudget(MODEL) && estimate < getContextWindow(MODEL) * 0.9) { + return currentMessages; + } + } + throw new Error("未能构造出位于输入预算与 90% 预检之间的 compact 测试样例"); +} + +describe("CompactService 自动压缩", () => { + 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(); + }); +}); diff --git a/src/app/service/agent/service_worker/compact_service.ts b/src/app/service/agent/service_worker/compact_service.ts index 901fce418..56ea62e5d 100644 --- a/src/app/service/agent/service_worker/compact_service.ts +++ b/src/app/service/agent/service_worker/compact_service.ts @@ -15,7 +15,7 @@ import { import { uuidv4 } from "@App/pkg/utils/uuid"; import type { AgentModelService } from "./model_service"; import { elideUntilWithinBudget, loadAttachmentSizes } from "@App/app/service/agent/core/context_elision"; -import { getContextWindow } from "@App/app/service/agent/core/model_context"; +import { getInputTokenBudget } from "@App/app/service/agent/core/model_context"; /** LLM 调用结果(与 AgentService.callLLM 返回值一致) */ interface CompactLLMResult { @@ -67,7 +67,9 @@ export class CompactService { summaryMessages.push({ role: "user", content: buildCompactUserPrompt() }); const attachmentSizes = await loadAttachmentSizes(summaryMessages, (id) => this.chatRepo.getAttachment(id)); - if (!elideUntilWithinBudget(summaryMessages, getContextWindow(model), undefined, 0.9, attachmentSizes, model)) { + 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", }); 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 f212d994d..fa69d4946 100644 --- a/src/app/service/content/gm_api/cat_agent.test.ts +++ b/src/app/service/content/gm_api/cat_agent.test.ts @@ -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(), @@ -656,3 +674,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 6d4f5dd45..5a0b5b8d5 100644 --- a/src/app/service/content/gm_api/cat_agent.ts +++ b/src/app/service/content/gm_api/cat_agent.ts @@ -171,6 +171,7 @@ function processChat(this: Instance, conn: MessageConnect, handlers: Map; + /** Final snapshot status emitted by attach(). */ + status: "running" | "done" | "error"; + } + // ---- Chat message ---- /** A persisted chat message in a conversation. */ @@ -1034,6 +1077,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 15e02e3de..3be043409 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; } @@ -971,7 +979,12 @@ declare namespace CATAgent { /** 工具调用信息(用于 tool_call / tool_call_complete)。 */ toolCall?: ToolCallInfo; /** Token 用量(用于 done)。 */ - usage?: { inputTokens: number; outputTokens: number }; + usage?: { + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens?: number; + cacheReadInputTokens?: number; + }; /** 错误信息(用于 error)。 */ error?: string; /** 错误分类码:`"rate_limit"` | `"auth"` | `"tool_timeout"` | `"max_iterations"` | `"api_error"` */ @@ -980,6 +993,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"; + } + // ---- 聊天消息 ---- /** 对话中持久化的聊天消息。 */ @@ -1041,6 +1083,9 @@ declare namespace CATAgent { /** 发送消息并接收流式响应。 */ chatStream(content: MessageContent, options?: ChatOptions): Promise>; + /** 附加到后台运行中的对话,接收初始快照与后续流式数据。 */ + attach(): Promise>; + /** 获取此对话中的所有消息。 */ getMessages(): Promise; From 41ff1155ff6ba0c8e71b0473e7e2e79c4fa7e247 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:54:12 +0900 Subject: [PATCH 23/38] fix agent abort and summary budgeting --- src/app/service/agent/core/abort_utils.ts | 32 +++++++ .../agent/core/context_elision.test.ts | 11 +++ src/app/service/agent/core/context_elision.ts | 5 +- src/app/service/agent/core/mcp_client.ts | 18 ++-- .../service/agent/core/mcp_tool_executor.ts | 4 +- .../agent/core/providers/content_utils.ts | 9 +- .../agent/core/session_tool_registry.test.ts | 17 ++++ .../agent/core/session_tool_registry.ts | 5 +- .../agent/core/skill_script_executor.test.ts | 33 +++++++- .../agent/core/skill_script_executor.ts | 84 ++++++++++++++----- .../service/agent/core/tool_registry.test.ts | 24 +++++- src/app/service/agent/core/tool_registry.ts | 24 ++++-- .../agent/core/tools/execute_script.test.ts | 15 +++- .../agent/core/tools/execute_script.ts | 14 ++-- src/app/service/agent/core/tools/tab_tools.ts | 38 ++++++--- src/app/service/agent/core/tools/web_fetch.ts | 22 +++-- .../service/agent/core/tools/web_search.ts | 60 +++++++++---- src/app/service/agent/service_worker/agent.ts | 26 ++++-- .../agent/service_worker/chat_service_base.ts | 10 ++- .../service_worker/compact_service.test.ts | 19 +++++ .../agent/service_worker/compact_service.ts | 33 +++++++- .../agent/service_worker/skill_service.ts | 12 ++- .../tool_loop_orchestrator.test.ts | 13 +++ .../service_worker/tool_loop_orchestrator.ts | 11 +-- .../tool_loop_orchestrator_base.ts | 24 +++--- src/pkg/utils/with_timeout.ts | 25 +++++- src/types/scriptcat.d.ts | 2 + src/types/scriptcat.zh-CN.d.ts | 2 + 28 files changed, 459 insertions(+), 133 deletions(-) create mode 100644 src/app/service/agent/core/abort_utils.ts 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/context_elision.test.ts b/src/app/service/agent/core/context_elision.test.ts index cd861ac83..01a3b8b59 100644 --- a/src/app/service/agent/core/context_elision.test.ts +++ b/src/app/service/agent/core/context_elision.test.ts @@ -156,6 +156,17 @@ describe("上下文预算估算与裁剪", () => { 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" }] }, diff --git a/src/app/service/agent/core/context_elision.ts b/src/app/service/agent/core/context_elision.ts index 37319502f..0d30ecced 100644 --- a/src/app/service/agent/core/context_elision.ts +++ b/src/app/service/agent/core/context_elision.ts @@ -2,6 +2,7 @@ // 用于在触及 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]"; @@ -59,7 +60,9 @@ export function estimateRequestTokens( // 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 Number.POSITIVE_INFINITY; + if (size == null) { + return blockSum + new TextEncoder().encode(imageBlockFallbackText(block)).byteLength; + } return blockSum + (Math.ceil(size / 3) * 4 + 128); }, 0) ); 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.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/providers/content_utils.ts b/src/app/service/agent/core/providers/content_utils.ts index 69bacc3f5..5234f1865 100644 --- a/src/app/service/agent/core/providers/content_utils.ts +++ b/src/app/service/agent/core/providers/content_utils.ts @@ -26,13 +26,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), }; } 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/tool_registry.test.ts b/src/app/service/agent/core/tool_registry.test.ts index d9d5992c9..93cca0cfb 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 }; } @@ -112,7 +112,7 @@ describe("ToolRegistry", () => { await registry.execute([{ id: "tc_1", name: "get_weather", arguments: "" }]); - expect(executeSpy).toHaveBeenCalledWith({}); + expect(executeSpy).toHaveBeenCalledWith({}, undefined); }); it("内置工具抛出异常时应返回错误信息", async () => { @@ -162,7 +162,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 +205,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 +214,22 @@ 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("JSON 解析失败时应返回错误", async () => { const registry = new ToolRegistry(); const executor = createExecutor(async () => "ok"); diff --git a/src/app/service/agent/core/tool_registry.ts b/src/app/service/agent/core/tool_registry.ts index db4e8a891..5593efcb5 100644 --- a/src/app/service/agent/core/tool_registry.ts +++ b/src/app/service/agent/core/tool_registry.ts @@ -5,7 +5,7 @@ import { getExtFromMime } from "./content_utils"; // 工具执行器接口 export interface ToolExecutor { - execute(args: Record): Promise; + execute(args: Record, signal?: AbortSignal): Promise; } // 工具来源分类 @@ -25,7 +25,8 @@ export interface ToolEntry { // 脚本工具回调类型:将 tool calls 发送到 Sandbox 执行 export type ScriptToolCallback = ( - toolCalls: ToolCall[] + toolCalls: ToolCall[], + signal?: AbortSignal ) => Promise>; // 工具执行结果(可能含附件和子代理详情) @@ -44,7 +45,8 @@ export interface ToolExecutorLike { execute( toolCalls: ToolCall[], scriptCallback?: ScriptToolCallback | null, - excludeTools?: Set + excludeTools?: Set, + signal?: AbortSignal ): Promise; } @@ -174,9 +176,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 复用附件保存等共享逻辑 @@ -185,7 +188,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[] = []; @@ -217,7 +221,7 @@ export class ToolRegistry implements ToolExecutorLike { if (tc.arguments) { args = JSON.parse(tc.arguments); } - const rawResult = await tool.executor.execute(args); + const rawResult = await tool.executor.execute(args, signal); // 检查是否带附件或子代理详情 if (isToolResultWithAttachments(rawResult)) { @@ -241,10 +245,14 @@ export class ToolRegistry implements ToolExecutorLike { ); results.push(...builtinResults); + if (signal?.aborted) { + return results; + } + // 执行脚本工具 if (scriptCalls.length > 0) { if (scriptCallback) { - const scriptResults = await scriptCallback(scriptCalls); + const scriptResults = await scriptCallback(scriptCalls, signal); // 脚本工具也可能返回带附件的结构化结果 for (const sr of scriptResults) { try { 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 0fac12fdc..ac1aad6e3 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", undefined); }); it.concurrent("返回值为 undefined 时应转为 null", async () => { @@ -115,6 +115,19 @@ describe("execute_script 工具", () => { "execute_script timed out after 0.05s" ); }); + + 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("超大返回值截断", () => { diff --git a/src/app/service/agent/core/tools/execute_script.ts b/src/app/service/agent/core/tools/execute_script.ts index a9a30a317..62bab5231 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 { throwIfAborted } from "../abort_utils"; import { requireString } from "./param_utils"; export const EXECUTE_SCRIPT_DEFINITION: ToolDefinition = { @@ -61,7 +62,7 @@ function buildResultPayload(result: unknown, extra: Record): st 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,测试用 }; @@ -72,7 +73,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"); @@ -85,16 +87,18 @@ export function createExecuteScriptTool(deps: ExecuteScriptDeps): { 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 timed out after ${timeoutMs / 1000}s`), + signal ); return buildResultPayload(result, { target: "page", tab_id: actualTabId }); } // sandbox const result = await withTimeout( - deps.executeInSandbox(code), + deps.executeInSandbox(code, signal), timeoutMs, - () => new Error(`execute_script timed out after ${timeoutMs / 1000}s`) + () => new Error(`execute_script timed out after ${timeoutMs / 1000}s`), + signal ); return buildResultPayload(result, { target: "sandbox" }); }, 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/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/service_worker/agent.ts b/src/app/service/agent/service_worker/agent.ts index 195aa8f5c..21f4b117d 100644 --- a/src/app/service/agent/service_worker/agent.ts +++ b/src/app/service/agent/service_worker/agent.ts @@ -40,11 +40,11 @@ import { AgentConfigRepo, type AgentGeneralConfig } from "@App/app/service/agent 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"; @@ -120,15 +120,25 @@ 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) => { + const onAbort = () => { + void stopScript(this.sender, uuid); + reject(createAbortError()); + }; + signal.addEventListener("abort", onAbort, { once: true }); + execPromise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort)); + }); }, }, { @@ -224,7 +234,7 @@ export class AgentService { 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)); @@ -241,7 +251,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); @@ -402,8 +412,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/chat_service_base.ts b/src/app/service/agent/service_worker/chat_service_base.ts index ebbbe091b..a1ab259dc 100644 --- a/src/app/service/agent/service_worker/chat_service_base.ts +++ b/src/app/service/agent/service_worker/chat_service_base.ts @@ -45,7 +45,7 @@ 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; + executeInSandbox: (code: string, signal?: AbortSignal) => Promise; } /** ChatService 需要的 LLM 调用依赖 */ @@ -399,7 +399,7 @@ export class ChatService { // 预加载历史中已使用过的 skill 工具 if (enableTools) { - await this.preloadSkillsFromHistory(existingMessages, metaTools); + await this.preloadSkillsFromHistory(existingMessages, metaTools, abortController.signal); } // 构建消息列表并持久化用户消息 @@ -700,7 +700,8 @@ export class ChatService { */ private async preloadSkillsFromHistory( existingMessages: Awaited>, - metaTools: Array<{ definition: ToolDefinition; executor: ToolExecutor }> + metaTools: Array<{ definition: ToolDefinition; executor: ToolExecutor }>, + signal?: AbortSignal ): Promise { const loadSkillMeta = metaTools.find((mt) => mt.definition.name === "load_skill"); if (!loadSkillMeta) return; @@ -725,8 +726,9 @@ export class ChatService { // 预执行 load_skill 以注册动态工具(结果不需要,只需要副作用) for (const skillName of loadedSkillNames) { + if (signal?.aborted) break; try { - await loadSkillMeta.executor.execute({ skill_name: skillName }); + await loadSkillMeta.executor.execute({ skill_name: skillName }, signal); } catch { // 加载失败,跳过 } diff --git a/src/app/service/agent/service_worker/compact_service.test.ts b/src/app/service/agent/service_worker/compact_service.test.ts index 8d005fa87..f1f91c2e3 100644 --- a/src/app/service/agent/service_worker/compact_service.test.ts +++ b/src/app/service/agent/service_worker/compact_service.test.ts @@ -52,4 +52,23 @@ describe("CompactService 自动压缩", () => { 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 56ea62e5d..8110588b8 100644 --- a/src/app/service/agent/service_worker/compact_service.ts +++ b/src/app/service/agent/service_worker/compact_service.ts @@ -14,8 +14,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, loadAttachmentSizes } from "@App/app/service/agent/core/context_elision"; +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 { @@ -56,6 +61,8 @@ export class CompactService { sendEvent: (event: ChatStreamEvent) => void, signal: AbortSignal ): Promise { + throwIfAborted(signal); + // 构建摘要请求(用 currentMessages 而非从 repo 加载,因为可能有未持久化的 tool 消息) const summaryMessages: ChatRequest["messages"] = []; summaryMessages.push({ role: "system", content: COMPACT_SYSTEM_PROMPT }); @@ -107,7 +114,9 @@ export class CompactService { } /** 使用 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"] = [ @@ -122,17 +131,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/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/tool_loop_orchestrator.test.ts b/src/app/service/agent/service_worker/tool_loop_orchestrator.test.ts index 7a38af742..0fe0de557 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator.test.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator.test.ts @@ -104,6 +104,19 @@ describe("ToolLoopOrchestrator 循环检测升级(loop-guard escalation)", ( }); }); + 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("续接长历史时,首个 LLM 请求使用裁剪后的副本且不修改原始历史", async () => { const oldToolResult = "完整工具结果".repeat(100); const messages: ChatRequest["messages"] = []; 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 43c96947d..93770b9f2 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator.ts @@ -1,6 +1,5 @@ import type { AgentChatRepo } from "@App/app/repo/agent_chat"; -import type { AgentModelConfig } from "@App/app/service/agent/core/types"; -import { getContextWindow, getInputTokenBudget } from "@App/app/service/agent/core/model_context"; +import { getInputTokenBudget } from "@App/app/service/agent/core/model_context"; import { ToolLoopOrchestrator as BaseToolLoopOrchestrator, type ToolLoopDeps } from "./tool_loop_orchestrator_base"; export type { ToolLoopDeps } from "./tool_loop_orchestrator_base"; @@ -13,13 +12,7 @@ export class ToolLoopOrchestrator { ) {} async callLLMWithToolLoop(params: Parameters[0]) { - const actualWindow = getContextWindow(params.model); const inputBudget = getInputTokenBudget(params.model); - const effectiveWindow = Math.max(1, Math.floor(inputBudget / 0.9)); - const model: AgentModelConfig = { - ...params.model, - contextWindow: Math.min(actualWindow, effectiveWindow), - }; const totalUsage = { inputTokens: 0, outputTokens: 0, @@ -51,6 +44,6 @@ export class ToolLoopOrchestrator { }, }); const orchestrator = new BaseToolLoopOrchestrator(deps, repo); - return orchestrator.callLLMWithToolLoop({ ...params, model }); + 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 index 2c777e80c..3904050b7 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator_base.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator_base.ts @@ -155,6 +155,7 @@ export class ToolLoopOrchestrator { // 本次调用使用的工具注册表(SessionToolRegistry 或 ToolRegistry) toolRegistry: ToolExecutorLike; model: AgentModelConfig; + inputTokenBudget?: number; messages: ChatRequest["messages"]; tools?: ToolDefinition[]; maxIterations: number; @@ -181,6 +182,7 @@ export class ToolLoopOrchestrator { const { toolRegistry, model, + inputTokenBudget, messages: inputMessages, tools, maxIterations, @@ -199,22 +201,16 @@ export class ToolLoopOrchestrator { cacheReadInputTokens: 0, }; const attachmentSizes = await loadAttachmentSizes(inputMessages, (id) => this.chatRepo.getAttachment(id)); + const budgetWindow = inputTokenBudget ?? getContextWindow(model); // 持久化历史保留完整结果供 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 / getContextWindow(model); + const estimatedUsageRatio = estimatedInputTokens / budgetWindow; if (estimatedUsageRatio >= ELISION_THRESHOLDS[0]) { - const withinBudget = elideUntilWithinBudget( - messages, - getContextWindow(model), - initialTools, - 0.9, - attachmentSizes, - model - ); + const withinBudget = elideUntilWithinBudget(messages, budgetWindow, initialTools, 0.9, attachmentSizes, model); if (!withinBudget) { await this.emitContextTooLarge(conversationId, totalUsage, startTime, sendEvent, throwOnTerminalError); return; @@ -246,12 +242,11 @@ export class ToolLoopOrchestrator { // 发送前预算检查:用上一轮真实 usage 判断是否需要裁剪只在响应之后生效, // 无法覆盖“上一轮新追加的 tool 结果单独撑爆下一次请求”的情况,必须在这里对 // 即将发出的完整请求(messages + 当前工具定义)做一次独立估算。 - const contextWindow = getContextWindow(model); const preflightTokens = estimateRequestTokens(messages, allToolDefs, attachmentSizes, model); - if (preflightTokens / contextWindow >= PREFLIGHT_BUDGET_RATIO) { + if (preflightTokens / budgetWindow >= PREFLIGHT_BUDGET_RATIO) { const withinBudget = elideUntilWithinBudget( messages, - contextWindow, + budgetWindow, allToolDefs, PREFLIGHT_BUDGET_RATIO, attachmentSizes, @@ -298,7 +293,7 @@ export class ToolLoopOrchestrator { let pendingElision = false; let contextUsageRatio: number | null = null; if (result.usage && conversationId) { - contextUsageRatio = getContextInputTokens(model, result.usage) / contextWindow; + contextUsageRatio = getContextInputTokens(model, result.usage) / budgetWindow; if (contextUsageRatio >= 0.8) { try { @@ -357,7 +352,8 @@ export class ToolLoopOrchestrator { // 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); + const toolResults = await toolRegistry.execute(result.toolCalls, scriptToolCallback, excludeToolsSet, signal); + if (signal.aborted) return; // 将 tool 结果加入消息,并通知 UI 工具执行完成 // 收集需要回写的 toolCall 元数据(执行状态 / 附件 / 子代理详情) diff --git a/src/pkg/utils/with_timeout.ts b/src/pkg/utils/with_timeout.ts index d9904ba2c..d60fe1f56 100644 --- a/src/pkg/utils/with_timeout.ts +++ b/src/pkg/utils/with_timeout.ts @@ -4,12 +4,31 @@ * @param ms 超时毫秒数 * @param onTimeoutError 可选:自定义超时错误构造器;默认抛 Error("operation timed out") */ -export function withTimeout(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.d.ts b/src/types/scriptcat.d.ts index 2f67e6edf..dee9197c4 100644 --- a/src/types/scriptcat.d.ts +++ b/src/types/scriptcat.d.ts @@ -979,6 +979,8 @@ declare namespace CATAgent { 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"` */ diff --git a/src/types/scriptcat.zh-CN.d.ts b/src/types/scriptcat.zh-CN.d.ts index 3be043409..cc53b73e8 100644 --- a/src/types/scriptcat.zh-CN.d.ts +++ b/src/types/scriptcat.zh-CN.d.ts @@ -985,6 +985,8 @@ declare namespace CATAgent { cacheCreationInputTokens?: number; cacheReadInputTokens?: number; }; + /** 总响应时长(毫秒)。 */ + durationMs?: number; /** 错误信息(用于 error)。 */ error?: string; /** 错误分类码:`"rate_limit"` | `"auth"` | `"tool_timeout"` | `"max_iterations"` | `"api_error"` */ From bd6c0d512588889bd312e7647b276acb18f72bd9 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:13:28 +0900 Subject: [PATCH 24/38] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20Agent?= =?UTF-8?q?=20=E5=8F=96=E6=B6=88=E5=92=8C=E7=94=A8=E9=87=8F=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/agent/core/agent.test.ts | 7 ++- .../agent/core/mcp_tool_executor.test.ts | 4 +- src/app/service/agent/core/opfs_helpers.ts | 50 ++++++++++----- .../service/agent/core/tool_registry.test.ts | 42 +++++++++++++ src/app/service/agent/core/tool_registry.ts | 17 ++++- .../agent/core/tools/execute_script.test.ts | 9 ++- .../agent/core/tools/execute_script.ts | 62 +++++++++++++++++-- .../agent/core/tools/opfs_tools.test.ts | 12 ++++ .../service/agent/core/tools/opfs_tools.ts | 35 +++++++---- .../agent/core/tools/tab_tools.test.ts | 2 +- .../agent/core/tools/task_tools.test.ts | 14 +++++ .../service/agent/core/tools/task_tools.ts | 20 ++++-- src/app/service/agent/service_worker/agent.ts | 7 +++ .../agent/service_worker/background.test.ts | 52 +++++++++++----- .../background_session_manager.ts | 23 ++++++- .../agent/service_worker/chat_service_base.ts | 6 +- .../service_worker/compact_service.test.ts | 21 +++++++ .../agent/service_worker/compact_service.ts | 4 +- .../tool_loop_orchestrator.test.ts | 45 ++++++++++++++ .../tool_loop_orchestrator_base.ts | 28 +++++++-- 20 files changed, 386 insertions(+), 74 deletions(-) diff --git a/src/app/service/agent/core/agent.test.ts b/src/app/service/agent/core/agent.test.ts index 369d2543f..f56d75ede 100644 --- a/src/app/service/agent/core/agent.test.ts +++ b/src/app/service/agent/core/agent.test.ts @@ -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/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/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/tool_registry.test.ts b/src/app/service/agent/core/tool_registry.test.ts index 93cca0cfb..e73287212 100644 --- a/src/app/service/agent/core/tool_registry.test.ts +++ b/src/app/service/agent/core/tool_registry.test.ts @@ -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"); @@ -230,6 +254,24 @@ describe("ToolRegistry", () => { 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"); diff --git a/src/app/service/agent/core/tool_registry.ts b/src/app/service/agent/core/tool_registry.ts index 5593efcb5..528eaeeb4 100644 --- a/src/app/service/agent/core/tool_registry.ts +++ b/src/app/service/agent/core/tool_registry.ts @@ -2,6 +2,7 @@ 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 { @@ -57,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; @@ -217,11 +227,12 @@ 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, signal); + const rawResult = await raceWithAbort(tool.executor.execute(args, signal), signal); // 检查是否带附件或子代理详情 if (isToolResultWithAttachments(rawResult)) { @@ -230,7 +241,7 @@ export class ToolRegistry implements ToolExecutorLike { } 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); @@ -252,7 +263,7 @@ export class ToolRegistry implements ToolExecutorLike { // 执行脚本工具 if (scriptCalls.length > 0) { if (scriptCallback) { - const scriptResults = await scriptCallback(scriptCalls, signal); + const scriptResults = await raceWithAbort(scriptCallback(scriptCalls, signal), signal); // 脚本工具也可能返回带附件的结构化结果 for (const sr of scriptResults) { try { 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 ac1aad6e3..83e7a9ad4 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", undefined); + expect(mockExecuteInSandbox).toHaveBeenCalledWith("return 1+2", expect.any(AbortSignal)); }); it.concurrent("返回值为 undefined 时应转为 null", async () => { @@ -107,13 +107,18 @@ describe("execute_script 工具", () => { }); 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 () => { diff --git a/src/app/service/agent/core/tools/execute_script.ts b/src/app/service/agent/core/tools/execute_script.ts index 62bab5231..9a8677a74 100644 --- a/src/app/service/agent/core/tools/execute_script.ts +++ b/src/app/service/agent/core/tools/execute_script.ts @@ -1,7 +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 { throwIfAborted } from "../abort_utils"; +import { createAbortError, throwIfAborted } from "../abort_utils"; import { requireString } from "./param_utils"; export const EXECUTE_SCRIPT_DEFINITION: ToolDefinition = { @@ -34,6 +34,57 @@ 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; @@ -94,11 +145,10 @@ export function createExecuteScriptTool(deps: ExecuteScriptDeps): { } // sandbox - const result = await withTimeout( - deps.executeInSandbox(code, signal), - timeoutMs, - () => new Error(`execute_script timed out after ${timeoutMs / 1000}s`), - signal + const result = await executeSandboxWithTimeout( + (executionSignal) => deps.executeInSandbox(code, executionSignal), + signal, + timeoutMs ); 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/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/service_worker/agent.ts b/src/app/service/agent/service_worker/agent.ts index 21f4b117d..b337141f1 100644 --- a/src/app/service/agent/service_worker/agent.ts +++ b/src/app/service/agent/service_worker/agent.ts @@ -132,11 +132,18 @@ export class AgentService { }); 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)); }); }, diff --git a/src/app/service/agent/service_worker/background.test.ts b/src/app/service/agent/service_worker/background.test.ts index 164cce020..153d6c171 100644 --- a/src/app/service/agent/service_worker/background.test.ts +++ b/src/app/service/agent/service_worker/background.test.ts @@ -404,19 +404,37 @@ describe("handleAttachToConversation 重连逻辑", () => { (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 会终态化会话、广播终态并在延迟后清理", 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); - simulateMessage({ action: "stop" }); + const { sender, sentMessages, simulateMessage } = createMockSender(); + await (service as any).handleAttachToConversation({ conversationId: "conv-stop" }, sender); - expect(rc.abortController.signal.aborted).toBe(true); + simulateMessage({ action: "stop" }); - (service as any).bgSessionManager.delete("conv-stop"); + expect(rc.abortController.signal.aborted).toBe(true); + expect(rc.status).toBe("error"); + expect(rc.pendingAskUser).toBeUndefined(); + expect(rc.askResolvers.size).toBe(0); + expect((service as any).bgSessionManager.has("conv-stop")).toBe(false); + expect(service.getRunningConversationIds()).not.toContain("conv-stop"); + expect( + sentMessages.some((message) => message.data?.type === "error" && message.data.errorCode === "cancelled") + ).toBe(true); + + await vi.advanceTimersByTimeAsync(30_000); + expect((service as any).bgSessionManager.get("conv-stop")).toBeUndefined(); + } finally { + vi.useRealTimers(); + } }); it("空 streamingState 的 sync 不包含 streamingMessage 字段", async () => { @@ -617,11 +635,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) { @@ -643,7 +661,11 @@ 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 () => { @@ -703,9 +725,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 94437aeab..b054d82d6 100644 --- a/src/app/service/agent/service_worker/background_session_manager.ts +++ b/src/app/service/agent/service_worker/background_session_manager.ts @@ -31,7 +31,7 @@ export class BackgroundSessionManager { private runningConversations = new Map(); has(conversationId: string): boolean { - return this.runningConversations.has(conversationId); + return this.runningConversations.get(conversationId)?.status === "running"; } get(conversationId: string): RunningConversation | undefined { @@ -47,7 +47,9 @@ export class BackgroundSessionManager { } listIds(): string[] { - return Array.from(this.runningConversations.keys()); + return Array.from(this.runningConversations.entries()) + .filter(([, conversation]) => conversation.status === "running") + .map(([conversationId]) => conversationId); } // 更新后台会话的流式状态快照 @@ -147,6 +149,21 @@ export class BackgroundSessionManager { } } + // 停止后台会话时必须同步终态化,避免保留为永远运行中的快照。 + stop(conversationId: string): void { + const rc = this.runningConversations.get(conversationId); + if (!rc || rc.status !== "running") return; + + rc.pendingAskUser = undefined; + rc.askResolvers.clear(); + rc.abortController.abort(); + + const event: ChatStreamEvent = { type: "error", message: "Conversation cancelled", errorCode: "cancelled" }; + this.updateStreamingState(rc, event); + this.broadcastEvent(rc, event); + this.cleanupIfDone(conversationId); + } + // 附加 UI 连接到后台运行中的会话(同步快照 + listener + askUser resolver + stop) async handleAttach(params: { conversationId: string }, sender: IGetSender): Promise { if (!sender.isType(GetSenderType.CONNECT)) { @@ -205,7 +222,7 @@ export class BackgroundSessionManager { } } if (msg.action === "stop") { - rc.abortController.abort(); + this.stop(params.conversationId); } }); diff --git a/src/app/service/agent/service_worker/chat_service_base.ts b/src/app/service/agent/service_worker/chat_service_base.ts index a1ab259dc..6ec0f3034 100644 --- a/src/app/service/agent/service_worker/chat_service_base.ts +++ b/src/app/service/agent/service_worker/chat_service_base.ts @@ -310,7 +310,11 @@ export class ChatService { } } if (msg.action === "stop") { - abortController.abort(); + if (rc) { + this.bgSessionManager.stop(params.conversationId); + } else { + abortController.abort(); + } } }); diff --git a/src/app/service/agent/service_worker/compact_service.test.ts b/src/app/service/agent/service_worker/compact_service.test.ts index f1f91c2e3..ad3f02866 100644 --- a/src/app/service/agent/service_worker/compact_service.test.ts +++ b/src/app/service/agent/service_worker/compact_service.test.ts @@ -33,6 +33,27 @@ function makeCurrentMessages(): ChatRequest["messages"] { } 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() }; diff --git a/src/app/service/agent/service_worker/compact_service.ts b/src/app/service/agent/service_worker/compact_service.ts index 8110588b8..ee7f06156 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, @@ -60,7 +61,7 @@ export class CompactService { currentMessages: ChatRequest["messages"], sendEvent: (event: ChatStreamEvent) => void, signal: AbortSignal - ): Promise { + ): Promise { throwIfAborted(signal); // 构建摘要请求(用 currentMessages 而非从 repo 加载,因为可能有未持久化的 tool 消息) @@ -111,6 +112,7 @@ export class CompactService { // 通知 UI sendEvent({ type: "compact_done", summary, originalCount: -1 }); + return result.usage; } /** 使用 summary 模型对任意内容做提取/总结(供 tab 工具使用) */ 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 index 0fe0de557..33ce319d2 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator.test.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator.test.ts @@ -2,6 +2,8 @@ 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 = { @@ -117,6 +119,25 @@ describe("ToolLoopOrchestrator 循环检测升级(loop-guard escalation)", ( 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"] = []; @@ -286,4 +307,28 @@ describe("ToolLoopOrchestrator 请求前预算检查(防止 tool 结果把下 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_base.ts b/src/app/service/agent/service_worker/tool_loop_orchestrator_base.ts index 3904050b7..6c8c67804 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator_base.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator_base.ts @@ -10,6 +10,7 @@ import type { 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"; @@ -97,7 +98,7 @@ export interface ToolLoopDeps { messages: ChatRequest["messages"], sendEvent: (event: ChatStreamEvent) => void, signal: AbortSignal - ): Promise; + ): Promise; } export class ToolLoopOrchestrator { @@ -202,15 +203,24 @@ export class ToolLoopOrchestrator { }; 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 / budgetWindow; + const estimatedUsageRatio = estimatedInputTokens / preflightBudgetWindow; if (estimatedUsageRatio >= ELISION_THRESHOLDS[0]) { - const withinBudget = elideUntilWithinBudget(messages, budgetWindow, initialTools, 0.9, attachmentSizes, model); + const withinBudget = elideUntilWithinBudget( + messages, + preflightBudgetWindow, + initialTools, + PREFLIGHT_BUDGET_RATIO, + attachmentSizes, + model + ); if (!withinBudget) { await this.emitContextTooLarge(conversationId, totalUsage, startTime, sendEvent, throwOnTerminalError); return; @@ -243,10 +253,10 @@ export class ToolLoopOrchestrator { // 无法覆盖“上一轮新追加的 tool 结果单独撑爆下一次请求”的情况,必须在这里对 // 即将发出的完整请求(messages + 当前工具定义)做一次独立估算。 const preflightTokens = estimateRequestTokens(messages, allToolDefs, attachmentSizes, model); - if (preflightTokens / budgetWindow >= PREFLIGHT_BUDGET_RATIO) { + if (preflightTokens / preflightBudgetWindow >= PREFLIGHT_BUDGET_RATIO) { const withinBudget = elideUntilWithinBudget( messages, - budgetWindow, + preflightBudgetWindow, allToolDefs, PREFLIGHT_BUDGET_RATIO, attachmentSizes, @@ -297,7 +307,13 @@ export class ToolLoopOrchestrator { if (contextUsageRatio >= 0.8) { try { - await this.deps.autoCompact(conversationId, model, messages, sendEvent, signal); + 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) { throw Object.assign(error instanceof Error ? error : new Error(String(error)), { usage: totalUsage, From ae540d44bfe97507c910db83c5384ee65fecf437 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:03:49 +0900 Subject: [PATCH 25/38] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E4=BC=9A=E8=AF=9D=E5=8F=96=E6=B6=88=E7=AB=9E?= =?UTF-8?q?=E6=80=81=E3=80=81=E5=8E=8B=E7=BC=A9=E7=BB=88=E6=AD=A2=E7=AB=9E?= =?UTF-8?q?=E6=80=81=E4=B8=8E=E7=AB=AF=E5=8F=A3=E6=B3=84=E6=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BackgroundSessionManager 新增 cancelling 占位态:stop() 不再立即终态化, 避免旧执行的 promise 尚未落定时同一 conversationId 被新会话顶替/误伤 (stop 支持按 rc 实例校验,防止旧连接的延迟 Stop 误杀顶替上位的新会话) - 自动/手动 compact 在 LLM 调用与持久化后补充 abort 检查,避免取消后仍 覆盖历史、落库摘要或广播 compact_done/done - tool loop 编排器新增统一的取消终态化路径:回写挂起中的 tool call 状态、 持久化终态记录、携带累计 usage/耗时、只发一次终态事件 - context_too_large 终态消息直接持久化 totalUsage,移除只覆盖 callLLM 未覆盖 autoCompact 的旧 usage 补丁 - estimateRequestTokens 不再把 UTF-8 字节数当 token 数,按保守换算折算 - execute_script(target=page) 明确告知 chrome.scripting.executeScript 无法真正中止:超时/取消只是调用方停止等待,页面代码可能仍在继续执行 - 修复 UI 与脚本适配器的连接泄漏:options 页面 hook 和 cat_agent 的 processChat/processStream 在终态事件后主动 disconnect,并为 processStream 的异步迭代器补上 return()/throw() Co-Authored-By: Claude Sonnet 5 --- .../agent/core/context_elision.test.ts | 5 +- src/app/service/agent/core/context_elision.ts | 8 ++- .../agent/core/tools/execute_script.test.ts | 2 +- .../agent/core/tools/execute_script.ts | 14 +++- .../agent/service_worker/background.test.ts | 25 ++++++- .../background_session_manager.ts | 32 ++++++--- .../agent/service_worker/chat_service_base.ts | 28 ++++++-- .../agent/service_worker/compact_service.ts | 7 ++ .../service_worker/tool_loop_orchestrator.ts | 34 +-------- .../tool_loop_orchestrator_base.ts | 69 +++++++++++++++++-- src/app/service/content/gm_api/cat_agent.ts | 25 +++++++ src/pages/options/routes/Agent/Chat/hooks.ts | 5 ++ 12 files changed, 197 insertions(+), 57 deletions(-) diff --git a/src/app/service/agent/core/context_elision.test.ts b/src/app/service/agent/core/context_elision.test.ts index 01a3b8b59..6fe30621e 100644 --- a/src/app/service/agent/core/context_elision.test.ts +++ b/src/app/service/agent/core/context_elision.test.ts @@ -149,7 +149,10 @@ describe("上下文预算估算与裁剪", () => { { role: "user", content: "继续" }, ]; const sizes = new Map([["old", 6000]]); - expect(estimateRequestTokens(messages, [], sizes, VISION_MODEL)).toBeGreaterThan(6000); + // 估算以字节为单位再折算为保守 token 数(见 CONSERVATIVE_BYTES_PER_TOKEN), + // 因此不能直接与原始字节数 6000 比较;改为验证 base64 展开确实被计入 + // —— 若只是把原始字节数朴素折半(3000),结果不会超过它。 + expect(estimateRequestTokens(messages, [], sizes, VISION_MODEL)).toBeGreaterThan(3000); 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"); diff --git a/src/app/service/agent/core/context_elision.ts b/src/app/service/agent/core/context_elision.ts index 0d30ecced..277db79e1 100644 --- a/src/app/service/agent/core/context_elision.ts +++ b/src/app/service/agent/core/context_elision.ts @@ -38,6 +38,12 @@ export async function loadAttachmentSizes( 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; + /** * 以 UTF-8 字节数的保守上界估算 provider 请求 token。 * 只对 provider 实际会内联展开为 base64 的块计入二进制字节: @@ -69,7 +75,7 @@ export function estimateRequestTokens( }, 0); if (!Number.isFinite(attachmentBytes)) return Number.POSITIVE_INFINITY; const bytes = new TextEncoder().encode(JSON.stringify({ messages, tools })).byteLength; - return bytes + attachmentBytes; + return Math.ceil((bytes + attachmentBytes) / CONSERVATIVE_BYTES_PER_TOKEN); } /** 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 83e7a9ad4..d4ce1db86 100644 --- a/src/app/service/agent/core/tools/execute_script.test.ts +++ b/src/app/service/agent/core/tools/execute_script.test.ts @@ -102,7 +102,7 @@ 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" ); }); diff --git a/src/app/service/agent/core/tools/execute_script.ts b/src/app/service/agent/core/tools/execute_script.ts index 9a8677a74..ebc99c640 100644 --- a/src/app/service/agent/core/tools/execute_script.ts +++ b/src/app/service/agent/core/tools/execute_script.ts @@ -9,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", @@ -134,11 +137,18 @@ 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 buildResultPayload(result, { target: "page", tab_id: actualTabId }); diff --git a/src/app/service/agent/service_worker/background.test.ts b/src/app/service/agent/service_worker/background.test.ts index 153d6c171..e51845ba7 100644 --- a/src/app/service/agent/service_worker/background.test.ts +++ b/src/app/service/agent/service_worker/background.test.ts @@ -404,7 +404,7 @@ describe("handleAttachToConversation 重连逻辑", () => { (service as any).bgSessionManager.delete("conv-multi"); }); - it("通过 attach 发送 stop 会终态化会话、广播终态并在延迟后清理", async () => { + it("通过 attach 发送 stop 会先置为 cancelling 并广播终态,占位直到执行方 finalize 才真正清理", async () => { vi.useFakeTimers(); try { const { service } = createTestService(); @@ -421,15 +421,22 @@ describe("handleAttachToConversation 重连逻辑", () => { simulateMessage({ action: "stop" }); expect(rc.abortController.signal.aborted).toBe(true); - expect(rc.status).toBe("error"); + // 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(false); + expect((service as any).bgSessionManager.has("conv-stop")).toBe(true); expect(service.getRunningConversationIds()).not.toContain("conv-stop"); expect( sentMessages.some((message) => message.data?.type === "error" && message.data.errorCode === "cancelled") ).toBe(true); + // 模拟执行方在 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 { @@ -437,6 +444,18 @@ describe("handleAttachToConversation 重连逻辑", () => { } }); + 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); + + (service as any).bgSessionManager.stop("conv-race", staleRc); + + expect(currentRc.abortController.signal.aborted).toBe(false); + expect(currentRc.status).toBe("running"); + }); + it("空 streamingState 的 sync 不包含 streamingMessage 字段", async () => { const { service } = createTestService(); const rc = createRunningConversation({ status: "running" }); 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 b054d82d6..7df16909f 100644 --- a/src/app/service/agent/service_worker/background_session_manager.ts +++ b/src/app/service/agent/service_worker/background_session_manager.ts @@ -23,7 +23,9 @@ export type RunningConversation = { }; 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 附加逻辑 @@ -31,7 +33,8 @@ export class BackgroundSessionManager { private runningConversations = new Map(); has(conversationId: string): boolean { - return this.runningConversations.get(conversationId)?.status === "running"; + const status = this.runningConversations.get(conversationId)?.status; + return status === "running" || status === "cancelling"; } get(conversationId: string): RunningConversation | undefined { @@ -149,18 +152,29 @@ export class BackgroundSessionManager { } } - // 停止后台会话时必须同步终态化,避免保留为永远运行中的快照。 - stop(conversationId: string): void { + // 停止后台会话:仅置为 cancelling(占用态,阻止同 ID 会话被过早顶替), + // 真正的终态(done/error)由持有该 rc 实例的执行方在 promise 落定后写入,见 finalizeCancelled()。 + // expectedRc 用于校验调用方持有的会话实例仍是当前会话,防止旧连接的延迟 Stop 误伤已顶替上位的新会话。 + stop(conversationId: string, expectedRc?: RunningConversation): void { const rc = this.runningConversations.get(conversationId); - if (!rc || rc.status !== "running") return; + if (!rc || (expectedRc && rc !== expectedRc)) return; + if (rc.status !== "running") return; + rc.status = "cancelling"; rc.pendingAskUser = undefined; rc.askResolvers.clear(); rc.abortController.abort(); const event: ChatStreamEvent = { type: "error", message: "Conversation cancelled", errorCode: "cancelled" }; - this.updateStreamingState(rc, event); this.broadcastEvent(rc, event); + } + + // 执行方在 abort 落定、promise 真正 settle 后调用,把 cancelling 收敛为终态。 + // 通过实例比对避免误将已被新会话顶替的 map 条目错误终态化。 + finalizeCancelled(conversationId: string, rc: RunningConversation): void { + if (this.runningConversations.get(conversationId) !== rc) return; + if (rc.status !== "cancelling") return; + rc.status = "error"; this.cleanupIfDone(conversationId); } @@ -196,7 +210,9 @@ export class BackgroundSessionManager { : undefined, pendingAskUser: rc.pendingAskUser, tasks: rc.tasks, - status: rc.status, + // cancelling 对外等同于终态:晚到的重连者不会再收到任何后续广播(见 stop/finalizeCancelled), + // 应立即按已结束处理,而不是误报为 running 导致 UI 一直转圈等待 + status: rc.status === "cancelling" ? "error" : rc.status, }; sendEvent(syncEvent); @@ -222,7 +238,7 @@ export class BackgroundSessionManager { } } if (msg.action === "stop") { - this.stop(params.conversationId); + this.stop(params.conversationId, rc); } }); diff --git a/src/app/service/agent/service_worker/chat_service_base.ts b/src/app/service/agent/service_worker/chat_service_base.ts index 6ec0f3034..cdf542f7d 100644 --- a/src/app/service/agent/service_worker/chat_service_base.ts +++ b/src/app/service/agent/service_worker/chat_service_base.ts @@ -311,7 +311,7 @@ export class ChatService { } if (msg.action === "stop") { if (rc) { - this.bgSessionManager.stop(params.conversationId); + this.bgSessionManager.stop(params.conversationId, rc); } else { abortController.abort(); } @@ -434,17 +434,28 @@ export class ChatService { skipBuiltinTools: !enableTools, askUserForGuard: params.scriptUuid ? undefined : askUserForGuard, }); - // 后台模式:正常完成后延迟清理 - this.bgSessionManager.cleanupIfDone(params.conversationId); + // 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 也需要清理注册表 + // 后台模式:abort 后必须等待本次执行 promise 真正落定,才能把 cancelling 收敛为终态, + // 否则 stop() 造成的 cancelling 占位会一直阻塞同 ID 的新会话(见 finalizeCancelled) if (abortController.signal.aborted) { - this.bgSessionManager.cleanupIfDone(params.conversationId); + if (rc) { + this.bgSessionManager.finalizeCancelled(params.conversationId, rc); + } else { + this.bgSessionManager.cleanupIfDone(params.conversationId); + } return; } const errorMsg = e.message || "Unknown error"; @@ -564,6 +575,10 @@ export class ChatService { abortController.signal ); + // LLM 调用期间可能已被 stop:落库/广播终态事件前必须重新检查, + // 否则 cancelled 之后仍可能持久化摘要并发出 compact_done/done + if (abortController.signal.aborted) return; + const summary = extractSummary(result.content); const originalCount = existingMessages.length; @@ -577,6 +592,9 @@ export class ChatService { }; await this.chatRepo.saveMessages(params.conversationId, [summaryMessage]); + // 持久化期间也可能已被 stop:终态事件只能在确认未取消时发送 + if (abortController.signal.aborted) return; + sendEvent({ type: "compact_done", summary, originalCount }); sendEvent({ type: "done", usage: result.usage }); } diff --git a/src/app/service/agent/service_worker/compact_service.ts b/src/app/service/agent/service_worker/compact_service.ts index ee7f06156..3dc7279d0 100644 --- a/src/app/service/agent/service_worker/compact_service.ts +++ b/src/app/service/agent/service_worker/compact_service.ts @@ -92,6 +92,10 @@ export class CompactService { signal ); + // LLM 调用期间可能已被 stop:摘要落地前必须重新检查,避免用过期摘要覆盖 currentMessages + // 或在取消之后仍持久化/广播 compact_done + throwIfAborted(signal); + const summary = extractSummary(result.content); // 替换 currentMessages(保留 system,替换其余为摘要) @@ -110,6 +114,9 @@ export class CompactService { }; await this.chatRepo.saveMessages(conversationId, [summaryMessage]); + // 持久化期间也可能已被 stop:终态事件只能在确认未取消时广播 + throwIfAborted(signal); + // 通知 UI sendEvent({ type: "compact_done", summary, originalCount: -1 }); return result.usage; 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 93770b9f2..41def8242 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator.ts @@ -4,7 +4,7 @@ import { ToolLoopOrchestrator as BaseToolLoopOrchestrator, type ToolLoopDeps } f export type { ToolLoopDeps } from "./tool_loop_orchestrator_base"; -/** 在原编排器外层统一预留输出 token,并补齐终态上下文错误的 usage 持久化。 */ +/** 在原编排器外层统一预留输出 token 预算。 */ export class ToolLoopOrchestrator { constructor( private deps: ToolLoopDeps, @@ -13,37 +13,7 @@ export class ToolLoopOrchestrator { async callLLMWithToolLoop(params: Parameters[0]) { const inputBudget = getInputTokenBudget(params.model); - const totalUsage = { - inputTokens: 0, - outputTokens: 0, - cacheCreationInputTokens: 0, - cacheReadInputTokens: 0, - }; - const deps: ToolLoopDeps = { - ...this.deps, - callLLM: async (...args) => { - const result = await this.deps.callLLM(...args); - 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; - } - return result; - }, - }; - const repo = new Proxy(this.chatRepo, { - get: (target, property, receiver) => { - if (property !== "appendMessage") return Reflect.get(target, property, receiver); - return (message: any) => - target.appendMessage( - message.errorCode === "context_too_large" && !message.usage - ? { ...message, usage: { ...totalUsage } } - : message - ); - }, - }); - const orchestrator = new BaseToolLoopOrchestrator(deps, repo); + 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 index 6c8c67804..f2d14b962 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator_base.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator_base.ts @@ -137,6 +137,7 @@ export class ToolLoopOrchestrator { content: "", error: error.message, errorCode: error.errorCode, + usage: totalUsage, durationMs: error.durationMs, createtime: Date.now(), }); @@ -151,6 +152,41 @@ export class ToolLoopOrchestrator { if (throwOnTerminalError) throw error; } + /** 取消(stop)落定时的统一终态化:持久化一条终态记录 + 发送唯一的终态事件,携带累计 usage/耗时。 */ + 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) { + await this.chatRepo.appendMessage({ + id: uuidv4(), + conversationId, + role: "assistant", + content: "", + error: "Conversation cancelled", + errorCode: "cancelled", + usage: totalUsage, + durationMs, + createtime: Date.now(), + }); + } + sendEvent({ + type: "error", + message: "Conversation cancelled", + errorCode: "cancelled", + usage: totalUsage, + durationMs, + }); + } + // 统一的 tool calling 循环,UI 和脚本共用 async callLLMWithToolLoop(params: { // 本次调用使用的工具注册表(SessionToolRegistry 或 ToolRegistry) @@ -289,9 +325,7 @@ export class ToolLoopOrchestrator { }); } - if (signal.aborted) return; - - // 累计 usage + // 先累计本轮 usage 再检查 aborted:即使取消发生在这次响应之后,其花费也不应从终态 usage 中丢失 if (result.usage) { totalUsage.inputTokens += result.usage.inputTokens; totalUsage.outputTokens += result.usage.outputTokens; @@ -299,6 +333,11 @@ export class ToolLoopOrchestrator { 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; @@ -321,6 +360,8 @@ export class ToolLoopOrchestrator { conversationId, }); } + // autoCompact 期间可能已被 stop:继续持久化/发送最终消息前必须重新检查 + if (signal.aborted) return; } else { for (let i = 0; i < ELISION_THRESHOLDS.length; i++) { if (i > lastElisionThresholdIndex && contextUsageRatio >= ELISION_THRESHOLDS[i]) { @@ -369,7 +410,21 @@ export class ToolLoopOrchestrator { const excludeToolsSet = params.excludeTools && params.excludeTools.length > 0 ? new Set(params.excludeTools) : undefined; const toolResults = await toolRegistry.execute(result.toolCalls, scriptToolCallback, excludeToolsSet, signal); - if (signal.aborted) return; + 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 元数据(执行状态 / 附件 / 子代理详情) @@ -449,6 +504,12 @@ export class ToolLoopOrchestrator { } } + // 工具调用状态已全部回写完毕,取消可以安全终态化了:只发一条终态事件,不再进入循环检测/下一轮 + 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) { diff --git a/src/app/service/content/gm_api/cat_agent.ts b/src/app/service/content/gm_api/cat_agent.ts index 5a0b5b8d5..964da82ca 100644 --- a/src/app/service/content/gm_api/cat_agent.ts +++ b/src/app/service/content/gm_api/cat_agent.ts @@ -212,6 +212,7 @@ function processChat(this: Instance, conn: MessageConnect, handlers: Map { + if (done) return; + done = true; + try { + conn.disconnect(); + } catch { + // port 可能已断开 + } + wake?.(); + }; + return { [Symbol.asyncIterator]() { return { @@ -381,6 +398,14 @@ function processStream( } 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; + }, }; }, }; diff --git a/src/pages/options/routes/Agent/Chat/hooks.ts b/src/pages/options/routes/Agent/Chat/hooks.ts index a1fd74343..cf05eda17 100644 --- a/src/pages/options/routes/Agent/Chat/hooks.ts +++ b/src/pages/options/routes/Agent/Chat/hooks.ts @@ -228,6 +228,8 @@ export function useStreamingChat() { setIsStreaming(false); setAskUserPending(null); connRef.current = null; + // 终态事件后必须主动断开,否则 port/listener 会一直挂在 SW 侧,直到用户手动刷新页面 + conn.disconnect(); onDone(); } }); @@ -289,6 +291,8 @@ export function useStreamingChat() { // done 或 error,无需保持连接 setIsStreaming(false); connRef.current = null; + // 终态 sync 后必须主动断开,否则 port/listener 会一直挂在 SW 侧 + conn.disconnect(); onDone(); } return; @@ -298,6 +302,7 @@ export function useStreamingChat() { setIsStreaming(false); setAskUserPending(null); connRef.current = null; + conn.disconnect(); onDone(); } }); From 2f7467f18cd47492da305f3e0ed9d5ed1603ade5 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:11:18 +0900 Subject: [PATCH 26/38] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20compa?= =?UTF-8?q?ct=5Fservice=20=E6=B5=8B=E8=AF=95=E7=94=A8=E4=BE=8B=E8=B6=85?= =?UTF-8?q?=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit estimateRequestTokens 引入字节→token 保守折算后(见上一提交),命中目标 预算区间所需的字符数随之变大;原用例逐字符线性扫描长度、每次都做一次 O(len) 的 JSON.stringify,字符数变大后总耗时成倍增长,在 CI 上超过 340ms 的 fast 用例超时导致失败。 改为二分查找最小满足长度,迭代次数从 O(n) 降到 O(log n),用例总耗时从 超时降回个位数毫秒。 Co-Authored-By: Claude Sonnet 5 --- .../service_worker/compact_service.test.ts | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/app/service/agent/service_worker/compact_service.test.ts b/src/app/service/agent/service_worker/compact_service.test.ts index ad3f02866..9fbd36671 100644 --- a/src/app/service/agent/service_worker/compact_service.test.ts +++ b/src/app/service/agent/service_worker/compact_service.test.ts @@ -16,19 +16,33 @@ const MODEL: AgentModelConfig = { maxTokens: 2_000, }; +// 二分查找最小长度而非逐字符递增:estimateRequestTokens 引入字节→token 折算后, +// 命中目标区间所需的字符数随之变大,逐字符线性扫描(每次都要 O(len) 的 JSON.stringify) +// 会在字符数翻倍后耗时成倍增长,曾在 CI 上导致该用例超时。 function makeCurrentMessages(): ChatRequest["messages"] { - for (let len = 1; len < 20_000; len++) { - const currentMessages: ChatRequest["messages"] = [{ role: "user", content: "x".repeat(len) }]; + 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 }, - ...currentMessages, + ...buildMessages(len), { role: "user", content: buildCompactUserPrompt() }, ]; - const estimate = estimateRequestTokens(summaryMessages, undefined, undefined, MODEL); - if (estimate > getInputTokenBudget(MODEL) && estimate < getContextWindow(MODEL) * 0.9) { - return currentMessages; - } + 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 测试样例"); } From c4b7dfa0e440feaf07de020da15111f10a6f01f7 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:17:33 +0900 Subject: [PATCH 27/38] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20proce?= =?UTF-8?q?ssChat=20=E5=9C=A8=20done/error=20=E6=97=B6=E5=9B=A0=20disconne?= =?UTF-8?q?ct=20=E6=97=B6=E5=BA=8F=E8=A2=AB=E8=AF=AF=E5=88=A4=E4=B8=BA?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E6=96=AD=E5=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一提交在 processChat 的 done/error 分支里,先调用 conn.disconnect() 再 resolve/reject。部分连接实现的 disconnect() 会同步触发已注册的 onDisconnect 回调,而 onDisconnect 里注册的是 reject(new Error("Connection disconnected"));Promise 只认第一次 settle,于是 disconnect() 抢在 resolve() 之前把整个 promise reject 掉, 导致 CAT.agent.conversation 的 chat() 每次都以 "Connection disconnected" 报错——对应 e2e basic chat / tool calling / multi-turn / error-retry 用例 失败。 修复:改为先 resolve/reject 再 disconnect,并加 settled 标记让 onDisconnect 回调在已经终态化之后变成 no-op,不再依赖 disconnect() 是否同步触发回调这一实现细节。 本地复现并验证: - npx playwright test e2e/agent-conversation.spec.ts(3 项全过) - npx playwright test e2e/agent-error-handling.spec.ts(通过) - npx vitest run(3090 项全过) Co-Authored-By: Claude Sonnet 5 --- src/app/service/content/gm_api/cat_agent.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/app/service/content/gm_api/cat_agent.ts b/src/app/service/content/gm_api/cat_agent.ts index 964da82ca..91b6996b1 100644 --- a/src/app/service/content/gm_api/cat_agent.ts +++ b/src/app/service/content/gm_api/cat_agent.ts @@ -124,6 +124,9 @@ function mergeTools(this: Instance, callTools?: Array) { 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[] = []; @@ -212,7 +215,7 @@ function processChat(this: Instance, conn: MessageConnect, handlers: Map reject(new Error("Connection disconnected"))); + conn.onDisconnect(() => { + if (settled) return; + settled = true; + reject(new Error("Connection disconnected")); + }); }); } From 7e7c1f2cd9d9e735b5e45c99a92ddced95854ca3 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:49:10 +0900 Subject: [PATCH 28/38] =?UTF-8?q?=E2=9A=A1=20=E9=99=8D=E4=BD=8E=20tool=20l?= =?UTF-8?q?oop=20=E5=B7=A5=E5=85=B7=E8=B0=83=E7=94=A8=E5=9B=9E=E5=86=99?= =?UTF-8?q?=E4=B8=8E=20elideUntilWithinBudget=20=E7=9A=84=E5=B8=B8?= =?UTF-8?q?=E6=95=B0=E5=BC=80=E9=94=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复查本 PR 引入/新增代码(tool_loop_orchestrator_base.ts、context_elision.ts 等新文件)的时间/空间复杂度后,修正两处真实的冗余开销(存储层为 OPFS JSON 文件读写,读写文件本身仍是 O(n),以下改动降低的是每轮 额外产生的冗余扫描/拷贝,而非改变存储层本身的渐进复杂度): - tool_loop_orchestrator_base.ts:工具调用结束后回写 toolCalls 状态, 原实现每轮都额外 getMessages()(整份读出会话历史)+ 反向线性扫描 (按 toolCalls 是否命中做 O(n) 的嵌套 some() 匹配)+ saveMessages() (整份覆写)。appendMessage 时其实已经拿到了带 id 的完整对象引用, 现在直接持有该引用并在工具执行完成后调用 chatRepo.updateMessage() 按 id 更新,去掉了编排器侧那份重复的全量消息拷贝与逐条 some() 匹配; 内存态 messages 的目标消息查找同理从正向 Array.find 改为从尾部反向 扫描(本轮只追加了少量消息,目标必在尾部附近)。 - context_elision.ts 的 elideUntilWithinBudget:原先按 hasToolResults 分两条 if 判断,但两分支条件完全相同,白白多做一次 O(n) 的 messages.some() 扫描;合并为一次判断,语义不变。 均为纯内部实现改进,不改变任何可观察行为;已补充/调整相关单测 (updateMessage mock、fake chatRepo 的按 id 存取语义)。 验证: - npx vitest run(3090 项全过) - npx tsc --noEmit / eslint / prettier --check 全部通过 - npx playwright test e2e/agent-conversation.spec.ts e2e/agent-error-handling.spec.ts e2e/agent-chat.spec.ts(5 项全过) Co-Authored-By: Claude Sonnet 5 --- src/app/service/agent/core/agent.test.ts | 1 + src/app/service/agent/core/context_elision.ts | 6 +-- .../service/agent/service_worker/llm.test.ts | 4 ++ .../agent/service_worker/test-helpers.ts | 31 ++++++++++++-- .../tool_loop_orchestrator.test.ts | 1 + .../tool_loop_orchestrator_base.ts | 41 +++++++++++-------- 6 files changed, 60 insertions(+), 24 deletions(-) diff --git a/src/app/service/agent/core/agent.test.ts b/src/app/service/agent/core/agent.test.ts index f56d75ede..46db08126 100644 --- a/src/app/service/agent/core/agent.test.ts +++ b/src/app/service/agent/core/agent.test.ts @@ -588,6 +588,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), }); diff --git a/src/app/service/agent/core/context_elision.ts b/src/app/service/agent/core/context_elision.ts index 277db79e1..8e5ebe372 100644 --- a/src/app/service/agent/core/context_elision.ts +++ b/src/app/service/agent/core/context_elision.ts @@ -126,10 +126,10 @@ export function elideUntilWithinBudget( attachmentSizes?: Map, model?: AgentModelConfig ): boolean { - const hasToolResults = messages.some((message) => message.role === "tool"); + // 原先按 hasToolResults 分两条 if 判断,但两分支条件完全相同(结果都只取决于 estimate()), + // 白白多做一次 O(n) 的 messages.some() 扫描;合并为一次判断,语义不变。 const estimate = () => estimateRequestTokens(messages, tools, attachmentSizes, model); - if (!hasToolResults && estimate() / contextWindow < budgetRatio) return true; - if (hasToolResults && estimate() / contextWindow < budgetRatio) return true; + if (estimate() / contextWindow < budgetRatio) return true; for (let keep = 5; keep >= 0; keep--) { elideOldToolResults(messages, keep); if (estimate() / contextWindow < budgetRatio) return true; diff --git a/src/app/service/agent/service_worker/llm.test.ts b/src/app/service/agent/service_worker/llm.test.ts index 2b17c89ec..d140928e0 100644 --- a/src/app/service/agent/service_worker/llm.test.ts +++ b/src/app/service/agent/service_worker/llm.test.ts @@ -720,6 +720,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")); diff --git a/src/app/service/agent/service_worker/test-helpers.ts b/src/app/service/agent/service_worker/test-helpers.ts index 17b86146a..253c9c69e 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), 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 index 33ce319d2..0dec30e3c 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator.test.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator.test.ts @@ -20,6 +20,7 @@ function makeFakeChatRepo() { appendMessage: vi.fn().mockResolvedValue(undefined), getMessages: vi.fn().mockResolvedValue([]), saveMessages: vi.fn().mockResolvedValue(undefined), + updateMessage: vi.fn().mockResolvedValue(undefined), } as any; } 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 index f2d14b962..b78432039 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator_base.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator_base.ts @@ -4,6 +4,7 @@ import type { AgentModelConfig, ChatRequest, ChatStreamEvent, + ChatMessage, ToolDefinition, ToolCall, Attachment, @@ -385,9 +386,11 @@ export class ToolLoopOrchestrator { // 如果有 tool calls,需要执行并继续循环 if (result.toolCalls && result.toolCalls.length > 0 && allToolDefs.length > 0) { - // 持久化 assistant 消息(含 tool calls) + // 持久化 assistant 消息(含 tool calls)。保留对象引用(含 id), + // 后续回写状态时按 id 直接 updateMessage,避免再整份 getMessages+scan+saveMessages。 + let persistedAssistantMessage: ChatMessage | undefined; if (conversationId) { - await this.chatRepo.appendMessage({ + persistedAssistantMessage = { id: uuidv4(), conversationId, role: "assistant", @@ -395,7 +398,8 @@ export class ToolLoopOrchestrator { thinking: result.thinking ? { content: result.thinking } : undefined, toolCalls: result.toolCalls, createtime: Date.now(), - }); + }; + await this.chatRepo.appendMessage(persistedAssistantMessage); } // 将 assistant 消息加入上下文(带 toolCalls,供 provider 构建 tool_calls 字段) @@ -485,23 +489,24 @@ export class ToolLoopOrchestrator { } }; - // 内存上下文中的 assistant 消息 - const assistantMsg = messages.find( - (m) => m.role === "assistant" && m.toolCalls?.some((tc: ToolCall) => completedIds.has(tc.id)) - ); + // 内存上下文中的 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); - // 持久化的 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; - } - } + // 持久化:appendMessage 时已保留了带 id 的完整对象引用,直接按 id updateMessage, + // 不必再整份 getMessages 扫描 + saveMessages 覆写。 + if (persistedAssistantMessage?.toolCalls) { + await this.chatRepo.updateMessage(persistedAssistantMessage); } // 工具调用状态已全部回写完毕,取消可以安全终态化了:只发一条终态事件,不再进入循环检测/下一轮 From 58c7eb9da9a933f68f8842139b7dd9906383b100 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:06:19 +0900 Subject: [PATCH 29/38] =?UTF-8?q?=E2=9A=A1=20estimateRequestTokens=20?= =?UTF-8?q?=E6=8C=89=E6=B6=88=E6=81=AF=E7=BC=93=E5=AD=98=E5=BA=8F=E5=88=97?= =?UTF-8?q?=E5=8C=96=E5=AD=97=E8=8A=82=E6=95=B0=EF=BC=8C=E6=B6=88=E9=99=A4?= =?UTF-8?q?=20tool=20loop=20=E7=9A=84=20O(n=C2=B2)=20=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E8=AE=A1=E8=B4=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前复查标记为"值得做但先搁置"的架构性问题:tool loop 每轮 preflight 都对 整段 messages 做一次 JSON.stringify(O(n) 字节处理),R 轮下来是 O(R·n), 随对话增长趋近 O(n²),长对话里带较大 tool 结果(页面内容/搜索结果等, 常见到数十 KB)时是真实的性能热点。 实现增量缓存:按 message 对象身份缓存其 role/content/toolCallId 的序列化 字节数(WeakMap),这三个字段一旦写入基本不再变化;唯一会原地改写 content 的两处(elideOldToolResults / elideOldAttachments)都显式调用新增的 invalidateMessageByteCache() 使缓存失效。toolCalls 字段(status/attachments/ subAgentDetails 会在工具执行后原地变化)不缓存,每次单独重算——但其体积只 随"该条消息自身的工具调用数"增长,不随对话长度增长,代价很小。 为什么没有做成完全 O(n) 总量(而是 O(n) 总字节处理 + O(R·n) 廉价的按条求和): 评估过按数组引用做"只加新增项"的运行总量缓存,但会与 toolCalls 原地更新的 测试用例冲突(同一数组、同一长度、toolCalls.status 改变后必须能读到新值), 需要在 tool_loop_orchestrator_base.ts 等多处新增失效钩子才能安全,改动面和 风险明显超过收益,故未采用;当前方案已消除了真正昂贵的部分(大文本重复 序列化),按条求和本身是廉价操作。 已用真实量级基准验证(~20KB/tool 结果,见 PR 评论):800 轮对话场景下 从 9018ms 降到 398ms(约 22.7x),且随对话变长加速比持续提升。 新增测试覆盖三类关键正确性场景:elideOldToolResults/elideOldAttachments 改写 content 后估算值立即反映新内容、toolCalls.status 原地变化后不读到 缓存旧值、重复调用返回稳定一致结果。 验证: - npx vitest run(3094 项全过,含 4 条新增缓存正确性用例) - npx tsc --noEmit / eslint / prettier --check 全部通过 - npx playwright test e2e/agent-conversation.spec.ts e2e/agent-error-handling.spec.ts e2e/agent-chat.spec.ts(5 项全过) Co-Authored-By: Claude Sonnet 5 --- .../agent/core/context_elision.test.ts | 68 ++++++++++++++++++- src/app/service/agent/core/context_elision.ts | 40 ++++++++++- 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/src/app/service/agent/core/context_elision.test.ts b/src/app/service/agent/core/context_elision.test.ts index 6fe30621e..4a156cc87 100644 --- a/src/app/service/agent/core/context_elision.test.ts +++ b/src/app/service/agent/core/context_elision.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from "vitest"; import { elideOldToolResults, + elideOldAttachments, ELIDED_TOOL_RESULT_STUB, elideUntilWithinBudget, estimateRequestTokens, } from "./context_elision"; -import type { AgentModelConfig, ChatRequest } from "./types"; +import type { AgentModelConfig, ChatRequest, ToolCall } from "./types"; const VISION_MODEL: AgentModelConfig = { id: "m-vision", @@ -198,3 +199,68 @@ describe("上下文预算估算与裁剪", () => { expect(estimate).toBeLessThan(bigSize); }); }); + +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 index 8e5ebe372..41ef1ffc3 100644 --- a/src/app/service/agent/core/context_elision.ts +++ b/src/app/service/agent/core/context_elision.ts @@ -44,6 +44,32 @@ export async function loadAttachmentSizes( // 除以该常量后仍保持保守(不会低估),同时不再把每个 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; +} + /** * 以 UTF-8 字节数的保守上界估算 provider 请求 token。 * 只对 provider 实际会内联展开为 base64 的块计入二进制字节: @@ -74,7 +100,17 @@ export function estimateRequestTokens( ); }, 0); if (!Number.isFinite(attachmentBytes)) return Number.POSITIVE_INFINITY; - const bytes = new TextEncoder().encode(JSON.stringify({ messages, tools })).byteLength; + + 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; + } return Math.ceil((bytes + attachmentBytes) / CONSERVATIVE_BYTES_PER_TOKEN); } @@ -101,6 +137,7 @@ export function elideOldToolResults(messages: ChatRequest["messages"], keepLastA const m = messages[i]; if (m.role === "tool" && m.content !== ELIDED_TOOL_RESULT_STUB) { m.content = ELIDED_TOOL_RESULT_STUB; + invalidateMessageByteCache(m); } } } @@ -114,6 +151,7 @@ export function elideOldAttachments(messages: ChatRequest["messages"], keepLastM message.content = message.content.map((block) => block.type === "text" ? block : { type: "text", text: elidedAttachmentStub(block) } ); + invalidateMessageByteCache(message); } } From 2777ceaa569125aa7ce521aedeb6843bd5db82fc Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:37:15 +0900 Subject: [PATCH 30/38] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20SSE?= =?UTF-8?q?=20abort=20=E6=82=AC=E6=8C=82=E3=80=81=E5=8F=96=E6=B6=88?= =?UTF-8?q?=E7=BB=88=E6=80=81=E5=8F=8C=E4=B8=BB=E4=BD=93=E7=AB=9E=E6=80=81?= =?UTF-8?q?=E3=80=81compact=20=E7=A0=B4=E5=9D=8F=E6=80=A7=E8=A6=86?= =?UTF-8?q?=E5=86=99=E4=B8=8E=E5=B9=B6=E5=8F=91=E5=86=99=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. SSE 流 abort 时不再静默 resolve(High): - readSSEStream() abort 时改为 reject 并主动 cancel reader,不再让循环 静默退出——否则调用方(LLMClient.callLLM)的外层 Promise 永远不 settle。 - Anthropic 解析器补上缺失的终态兜底:流正常结束但没收到 message_stop/message_delta(usage)/error 时补发 error 事件。 - LLMClient.callLLM 增加独立订阅 signal 的最后一道保险,避免任何未预见 的静默完成路径导致 Promise 永远挂起。 2. 取消终态化统一为单一来源(High): - BackgroundSessionManager.stop() 不再自行广播终态事件,只置 cancelling 并 abort;终态事件的唯一来源是 orchestrator 的 emitCancelled()(带完整 usage/耗时)。此前 stop() 先广播一条不带 usage 的事件会导致 UI 提前 断开丢失后到的真实终态事件,也会让 finalizeCancelled() 因 status 已被 改写而不再触发清理。 - tool loop 编排器新增/修正多处取消收口:callLLM/toolRegistry.execute/ autoCompact 因 abort 而 reject 时统一走 emitCancelled 而不是直接 throw 跳过终态化;guard 等待用户回答期间 abort 不再误报 done;最终消息落库 后也重新检查 abort,不再在 Stop 之后仍报告 done。 3. compact 不再在 Stop 后完成破坏性覆写(High): - OPFSRepo.writeJsonFile 新增可选 signal:利用 createWritable() 本身的 事务性(write() 只写临时副本,close() 才原子替换),signal 在 close() 落定前 abort 时改为 writable.abort() 放弃这次整份覆写,旧内容保持完整。 - AgentChatRepo.saveMessages 透传 signal;compact_service.autoCompact 与 手动 compact 都改为写盘成功后才允许覆写内存中的 currentMessages。 4. 同一 conversationId 的 chat/compact/clear 现在严格排队执行(High): 复用仓库已有的 stackAsyncTask 按 conversationId 排队,避免并发的对话/ 压缩/清空互相用过期快照覆盖对方的持久化写入。 5. 用户脚本连接生命周期收尾(Medium): - attach 收到终态 sync 快照后立即 disconnect(SW 侧不会再为这条连接注册 listener,不断开会一直挂着 port)。 - processStreamEphemeral 的迭代器补上 return()/throw(),转发给内层 processStream 迭代器并在提前退出时提交已累积的部分输出。 - executeTools 结果发送前检查连接是否已 settled,并用 try/catch 包裹 sendMessage,避免用户脚本上下文出现 unhandled rejection。 6. token 估算器诚实化(Medium):文本/JSON 与图片字节数不再共用同一个折算 系数——图片 provider 计费与 base64 字节数没有对应关系,本仓库无图片宽高 元数据,无法套用具体公式,因此图片字节数不折算(宁可偏保守);补充注释 明确这是启发式估算而非任何 provider 的精确 tokenizer。 验证: - npx vitest run(3099 项全过,新增覆盖 SSE abort reject、stop 不再广播、 writeJsonFile 取消安全、conversationId 排队) - npx tsc --noEmit / eslint / prettier --check 全部通过 - npx playwright test(agent-conversation/agent-error-handling/agent-chat/ agent-navigation/agent-provider,13 项全过) Co-Authored-By: Claude Sonnet 5 --- src/app/repo/agent_chat.test.ts | 33 ++++++ src/app/repo/agent_chat.ts | 8 +- src/app/repo/opfs_repo.ts | 18 +++- src/app/service/agent/core/agent.test.ts | 17 ++- src/app/service/agent/core/context_elision.ts | 16 ++- .../agent/core/providers/anthropic.test.ts | 21 +++- .../service/agent/core/providers/anthropic.ts | 20 +++- .../agent/core/providers/content_utils.ts | 13 ++- .../agent/core/providers/openai.test.ts | 8 +- .../agent/service_worker/background.test.ts | 9 +- .../background_session_manager.ts | 11 +- .../service/agent/service_worker/chat.test.ts | 100 ++++++++++++++++++ .../agent/service_worker/chat_service_base.ts | 35 +++++- .../agent/service_worker/compact_service.ts | 23 ++-- .../agent/service_worker/llm_client.ts | 21 +++- .../tool_loop_orchestrator_base.ts | 40 ++++++- src/app/service/content/gm_api/cat_agent.ts | 55 ++++++++-- 17 files changed, 378 insertions(+), 70 deletions(-) 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/opfs_repo.ts b/src/app/repo/opfs_repo.ts index 626059777..89199df0b 100644 --- a/src/app/repo/opfs_repo.ts +++ b/src/app/repo/opfs_repo.ts @@ -56,12 +56,26 @@ export class OPFSRepo { } } - // 写入 JSON 文件 - protected async writeJsonFile(filename: string, data: unknown, dir?: FileSystemDirectoryHandle): Promise { + // 写入 JSON 文件。 + // OPFS 的 createWritable() 本身是事务性的:write() 写入的是临时副本,只有 close() 成功 + // 才会原子替换原文件;调用方持有的旧内容在此之前始终完整可读。 + // 传入 signal 时,若在 close() 落定前已 abort,则改为 writable.abort() 放弃这次写入, + // 避免"取消已经发生,但覆盖性写入仍然提交"的竞态(见 finding 4)。 + 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/agent.test.ts b/src/app/service/agent/core/agent.test.ts index 46db08126..6b692e923 100644 --- a/src/app/service/agent/core/agent.test.ts +++ b/src/app/service/agent/core/agent.test.ts @@ -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"); }); }); }); diff --git a/src/app/service/agent/core/context_elision.ts b/src/app/service/agent/core/context_elision.ts index 41ef1ffc3..ae4e93efa 100644 --- a/src/app/service/agent/core/context_elision.ts +++ b/src/app/service/agent/core/context_elision.ts @@ -71,7 +71,18 @@ function getStableContentBytes(message: { role: string; content: unknown; toolCa } /** - * 以 UTF-8 字节数的保守上界估算 provider 请求 token。 + * 请求 token 的启发式估算,不是任何 provider 的精确 tokenizer(本仓库未接入 + * tiktoken/Anthropic 官方计数器,引入新依赖超出当前改动范围)。分两部分独立估算, + * 因为文本/JSON 与 vision 图片的字节→token 比例机制完全不同,不应共用同一个换算系数: + * + * - 文本 + JSON 结构(messages/tools/toolCalls):按 UTF-8 字节数 / CONSERVATIVE_BYTES_PER_TOKEN + * 保守折算。这仍然是启发式而非保证:高熵内容或极端 tokenizer 差异下仍可能被低估, + * 调用方(preflight 预算检查、elideUntilWithinBudget)应把结果当作"大致上界"而非精确值。 + * - vision 图片:provider 的图片计费与 base64 字节数 / 2 没有对应关系(如 OpenAI 按分块、 + * Anthropic 按 宽×高/750),本仓库没有图片宽高元数据,无法套用具体公式;因此不对这部分 + * 字节数做任何折算(即按 1 字节 = 1 token 计),宁可在图片场景下偏保守(可能拒绝部分本可 + * 容纳的请求),也不假装能精确换算图片 token 从而低估导致请求超限。 + * * 只对 provider 实际会内联展开为 base64 的块计入二进制字节: * 当前仅 vision 模型的 image 块会被 resolveAttachments 加载;file/audio 及非 vision 模型的 * image 均降级为纯文本描述(见 providers/content_utils.ts),其体积已包含在下方的 JSON 基线字节里。 @@ -111,7 +122,8 @@ export function estimateRequestTokens( if (tools && tools.length > 0) { bytes += new TextEncoder().encode(JSON.stringify(tools)).byteLength; } - return Math.ceil((bytes + attachmentBytes) / CONSERVATIVE_BYTES_PER_TOKEN); + // 文本/JSON 部分按保守系数折算;图片字节数不折算(见函数注释),两者独立相加 + return Math.ceil(bytes / CONSERVATIVE_BYTES_PER_TOKEN) + attachmentBytes; } /** diff --git a/src/app/service/agent/core/providers/anthropic.test.ts b/src/app/service/agent/core/providers/anthropic.test.ts index 8d4568845..060741433 100644 --- a/src/app/service/agent/core/providers/anthropic.test.ts +++ b/src/app/service/agent/core/providers/anthropic.test.ts @@ -478,7 +478,7 @@ describe("parseAnthropicStream", () => { } }); - it("signal 已中止时应停止读取", async () => { + it("signal 已中止时应停止读取并 reject,而不是静默 resolve(否则外层 callLLM 永远挂起)", async () => { const controller = new AbortController(); controller.abort(); @@ -487,11 +487,24 @@ 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("流正常结束但没有 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 () => { @@ -514,7 +527,7 @@ describe("parseAnthropicStream", () => { } }); - it("读取错误但 signal 已中止时不应发送 error", async () => { + it("读取错误但 signal 已中止时应 reject 而不是发送 error", async () => { const controller = new AbortController(); const reader = { read: async () => { @@ -527,7 +540,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 5682f57a5..517c687f8 100644 --- a/src/app/service/agent/core/providers/anthropic.ts +++ b/src/app/service/agent/core/providers/anthropic.ts @@ -202,6 +202,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( @@ -303,6 +307,7 @@ export function parseAnthropicStream( case "message_delta": { // 消息结束,合并 message_start 的 input usage 和 message_delta 的 output usage if (json.usage) { + doneSent = true; onEvent({ type: "done", usage: { @@ -318,10 +323,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", @@ -334,8 +341,17 @@ 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" }); + } + }); } // ---- LLMProvider 接口适配 ---- diff --git a/src/app/service/agent/core/providers/content_utils.ts b/src/app/service/agent/core/providers/content_utils.ts index 5234f1865..95c4d9b75 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_{时间戳}_{随机串}.{扩展名} @@ -65,6 +66,11 @@ 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 }); try { while (!signal.aborted) { @@ -79,8 +85,13 @@ export async function readSSEStream( if (onEvent(sseEvent)) 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); } } diff --git a/src/app/service/agent/core/providers/openai.test.ts b/src/app/service/agent/core/providers/openai.test.ts index b145895fc..cde5c51c2 100644 --- a/src/app/service/agent/core/providers/openai.test.ts +++ b/src/app/service/agent/core/providers/openai.test.ts @@ -238,14 +238,14 @@ 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); }); @@ -272,7 +272,7 @@ describe("parseOpenAIStream", () => { } }); - it("读取错误但 signal 已中止时不应发送 error 事件", async () => { + it("读取错误但 signal 已中止时应 reject 而不是发送 error 事件", async () => { const controller = new AbortController(); const reader = { read: async () => { @@ -285,7 +285,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); }); diff --git a/src/app/service/agent/service_worker/background.test.ts b/src/app/service/agent/service_worker/background.test.ts index e51845ba7..f9799483d 100644 --- a/src/app/service/agent/service_worker/background.test.ts +++ b/src/app/service/agent/service_worker/background.test.ts @@ -404,7 +404,7 @@ describe("handleAttachToConversation 重连逻辑", () => { (service as any).bgSessionManager.delete("conv-multi"); }); - it("通过 attach 发送 stop 会先置为 cancelling 并广播终态,占位直到执行方 finalize 才真正清理", async () => { + it("通过 attach 发送 stop 只置为 cancelling 并 abort,不自行广播终态(终态事件唯一来源是执行方 emitCancelled)", async () => { vi.useFakeTimers(); try { const { service } = createTestService(); @@ -428,9 +428,10 @@ describe("handleAttachToConversation 重连逻辑", () => { expect(rc.askResolvers.size).toBe(0); expect((service as any).bgSessionManager.has("conv-stop")).toBe(true); expect(service.getRunningConversationIds()).not.toContain("conv-stop"); - expect( - sentMessages.some((message) => message.data?.type === "error" && message.data.errorCode === "cancelled") - ).toBe(true); + // 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); 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 7df16909f..3d111b1b1 100644 --- a/src/app/service/agent/service_worker/background_session_manager.ts +++ b/src/app/service/agent/service_worker/background_session_manager.ts @@ -152,8 +152,12 @@ export class BackgroundSessionManager { } } - // 停止后台会话:仅置为 cancelling(占用态,阻止同 ID 会话被过早顶替), - // 真正的终态(done/error)由持有该 rc 实例的执行方在 promise 落定后写入,见 finalizeCancelled()。 + // 停止后台会话:仅置为 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); @@ -164,9 +168,6 @@ export class BackgroundSessionManager { rc.pendingAskUser = undefined; rc.askResolvers.clear(); rc.abortController.abort(); - - const event: ChatStreamEvent = { type: "error", message: "Conversation cancelled", errorCode: "cancelled" }; - this.broadcastEvent(rc, event); } // 执行方在 abort 落定、promise 真正 settle 后调用,把 cancelling 收敛为终态。 diff --git a/src/app/service/agent/service_worker/chat.test.ts b/src/app/service/agent/service_worker/chat.test.ts index 1a26713cd..a89a54559 100644 --- a/src/app/service/agent/service_worker/chat.test.ts +++ b/src/app/service/agent/service_worker/chat.test.ts @@ -702,3 +702,103 @@ describe("scriptToolCallback 的 abort/disconnect/超时处理", () => { } }); }); + +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_base.ts b/src/app/service/agent/service_worker/chat_service_base.ts index cdf542f7d..e9c0c5529 100644 --- a/src/app/service/agent/service_worker/chat_service_base.ts +++ b/src/app/service/agent/service_worker/chat_service_base.ts @@ -37,6 +37,7 @@ 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"; @@ -121,8 +122,12 @@ export class ChatService { // 对话已经在 chat 过程中持久化,这里确保元数据也保存 return true; case "clearMessages": - await this.chatRepo.saveMessages(params.conversationId, []); - return true; + // 与 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}`); } @@ -149,6 +154,9 @@ export class ChatService { } // 统一的流式 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"); @@ -167,6 +175,26 @@ export class ChatService { 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; @@ -590,7 +618,8 @@ export class ChatService { content: `[Conversation Summary]\n\n${summary}`, createtime: Date.now(), }; - await this.chatRepo.saveMessages(params.conversationId, [summaryMessage]); + // 传入 signal:写入落定前若已 abort,则放弃这次整份覆写而不提交(见 finding 4) + await this.chatRepo.saveMessages(params.conversationId, [summaryMessage], abortController.signal); // 持久化期间也可能已被 stop:终态事件只能在确认未取消时发送 if (abortController.signal.aborted) return; diff --git a/src/app/service/agent/service_worker/compact_service.ts b/src/app/service/agent/service_worker/compact_service.ts index 3dc7279d0..848b2e2ee 100644 --- a/src/app/service/agent/service_worker/compact_service.ts +++ b/src/app/service/agent/service_worker/compact_service.ts @@ -92,19 +92,15 @@ export class CompactService { signal ); - // LLM 调用期间可能已被 stop:摘要落地前必须重新检查,避免用过期摘要覆盖 currentMessages - // 或在取消之后仍持久化/广播 compact_done + // LLM 调用期间可能已被 stop:落地前必须重新检查,避免取消之后仍持久化/广播 compact_done throwIfAborted(signal); const summary = extractSummary(result.content); - // 替换 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}` }); - - // 持久化 + // 持久化:先写盘、成功后才允许覆写内存中的 currentMessages。 + // OPFS 的 createWritable() 是事务性的,signal 在 close() 落定前 abort 时会放弃这次 + // 整份覆写而不提交(见 opfs_repo.ts writeJsonFile);只有 saveMessages 真正成功后, + // 再让内存态 currentMessages 反映同一份摘要,避免"写盘失败但内存已被摘要顶替"的不一致(见 finding 4) const summaryMessage = { id: uuidv4(), conversationId, @@ -112,10 +108,13 @@ 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:终态事件只能在确认未取消时广播 - 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 }); diff --git a/src/app/service/agent/service_worker/llm_client.ts b/src/app/service/agent/service_worker/llm_client.ts index ec076f115..d376666b3 100644 --- a/src/app/service/agent/service_worker/llm_client.ts +++ b/src/app/service/agent/service_worker/llm_client.ts @@ -135,6 +135,19 @@ 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(); + }; + const onAbortSafeguard = () => settleOnce(() => reject(new Error("Aborted"))); + 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 @@ -236,7 +249,7 @@ export class LLMClient { finalize() .then((contentBlocks) => { - resolve({ + resolveOnce({ content, thinking: thinking || undefined, toolCalls: toolCalls.length > 0 ? toolCalls : undefined, @@ -244,16 +257,16 @@ export class LLMClient { contentBlocks, }); }) - .catch(reject); + .catch(rejectOnce); break; } case "error": - reject(new Error(event.message)); + rejectOnce(new Error(event.message)); break; } }; - parseStream(reader, onEvent, signal).catch(reject); + parseStream(reader, onEvent, signal).catch(rejectOnce); }); } } 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 index b78432039..9a6d5651f 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator_base.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator_base.ts @@ -319,6 +319,12 @@ export class ToolLoopOrchestrator { signal ); } catch (error) { + // SSE 解析层现在会在 abort 时 reject(而不是静默挂起,见 content_utils.ts), + // 这类 reject 必须走统一的取消终态化路径,而不是当作真实错误往外抛 + 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, @@ -355,14 +361,22 @@ export class ToolLoopOrchestrator { 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:继续持久化/发送最终消息前必须重新检查 - if (signal.aborted) return; + // 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]) { @@ -413,7 +427,15 @@ export class ToolLoopOrchestrator { // 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, signal); + // 脚本工具通过 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 返回结果(如尚未触达的脚本工具), @@ -540,8 +562,9 @@ export class ToolLoopOrchestrator { if (guardStrikeCount >= GUARD_ESCALATION_STRIKES && params.askUserForGuard) { const answer = await waitForGuardAnswer(params.askUserForGuard, guardStrikeCount, signal); if (answer === null) { - const durationMs = Date.now() - startTime; - sendEvent({ type: "done", usage: totalUsage, durationMs }); + // waitForGuardAnswer 只在 signal abort 时才会返回 null,必须走取消终态化路径, + // 而不是当作正常完成发 done——否则 Stop 期间恰好卡在等待用户回答会被误报为成功 + await this.emitCancelled(conversationId, totalUsage, startTime, sendEvent); return; } if (answer.trim().toLowerCase() === GUARD_STOP_ANSWER.toLowerCase()) { @@ -604,6 +627,13 @@ export class ToolLoopOrchestrator { }); } + // 持久化期间也可能已被 stop:内容已经落库(不丢失),但终态事件必须反映取消, + // 不能在 Stop 之后仍对外报告 done(否则后台会话状态会被"成功"覆盖掉 cancelled) + if (signal.aborted) { + await this.emitCancelled(conversationId, totalUsage, startTime, sendEvent); + return; + } + // 发送 done 事件 sendEvent({ type: "done", usage: totalUsage, durationMs }); return; diff --git a/src/app/service/content/gm_api/cat_agent.ts b/src/app/service/content/gm_api/cat_agent.ts index 91b6996b1..5dcaaab85 100644 --- a/src/app/service/content/gm_api/cat_agent.ts +++ b/src/app/service/content/gm_api/cat_agent.ts @@ -165,11 +165,20 @@ function processChat(this: Instance, conn: MessageConnect, handlers: Map { if (message.action === "executeTools") { - conn.sendMessage({ - action: "toolResults", - requestId: message.requestId, - data: await executeTools.call(this, message.data, handlers), - }); + const data = await executeTools.call(this, message.data, handlers); + // 工具函数执行期间连接可能已经因 Stop/脚本工具超时而 settle 并断开; + // 断开后的连接 sendMessage 会抛错,且这里是异步回调,事件源不会 await/捕获它, + // 会在用户脚本上下文里变成 unhandled rejection(见 finding 7) + if (settled) return; + try { + conn.sendMessage({ + action: "toolResults", + requestId: message.requestId, + data, + }); + } catch { + // 连接已断开,结果无处可送,安全忽略 + } return; } if (message.action !== "event") return; @@ -267,11 +276,20 @@ function processStream( conn.onMessage(async (message: any) => { if (message.action === "executeTools") { - conn.sendMessage({ - action: "toolResults", - requestId: message.requestId, - data: await executeTools.call(this, message.data, handlers), - }); + const data = await executeTools.call(this, message.data, handlers); + // 工具函数执行期间连接可能已经因 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; @@ -293,6 +311,9 @@ function processStream( 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 }); @@ -470,6 +491,20 @@ function processStreamEphemeral( } 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; + }, }; }, }; From 40ad4b0e9ef35624c33771c2c86ef5d70630f75d Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 13 Jul 2026 01:14:41 +0900 Subject: [PATCH 31/38] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=8F=96=E6=B6=88=E7=BB=88=E6=80=81=E6=B8=85=E7=90=86=E6=BC=8F?= =?UTF-8?q?=E5=88=A4=E3=80=81SSE=20=E6=88=AA=E6=96=AD=E8=AF=AF=E5=88=A4?= =?UTF-8?q?=E6=88=90=E5=8A=9F=E3=80=81=E5=9B=BE=E7=89=87=20token=20?= =?UTF-8?q?=E4=B8=A5=E9=87=8D=E9=AB=98=E4=BC=B0=E7=AD=89=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. finalizeCancelled 清理漏判(High):emitCancelled() 广播的终态事件会先经过 sendEvent → updateStreamingState 把 status 从 cancelling 改写成 error, finalizeCancelled() 原本只认 cancelling,导致这个真实时序下永远不会调度 cleanupIfDone(),记录永久留在 runningConversations。改为 cancelling 或 已落定的终态都可以安全调度清理。cancelling 阶段的迟到 attach 也不再立即 报 error 断开连接:sync 快照对外仍按 running 处理并继续订阅 listener, 直到 orchestrator 真正广播出那条带完整取消原因/usage/耗时的终态事件。 2. 多个终态分支未在持久化后复查取消(High):loop-guard 用户选择 Stop 的路径、 超过 maxIterations 的路径、emitContextTooLarge 都补上了信号复查——持久化 期间/之后已被 Stop 时改走统一的取消终态化路径,不再对外报告 done/ max_iterations/context_too_large。emitCancelled 与 emitContextTooLarge 的持久化都改为 try/catch 包裹,落库失败不再阻塞终态事件发送。 3. abort 时 provider 侧图片落盘与 usage 未妥善处理(Medium):LLMClient 的 图片保存/markdown 内联图片提取流程改为逐步检查 settled,一旦外层 Promise 已经因 abort 落定就停止继续保存/发送事件,并清理这一轮已经落盘但用不上的 孤儿附件。OpenAI/Anthropic 解析器在 abort reject 时把已知的部分 usage (消息级 usage chunk / message_start 的 input+cache usage)带在错误上, orchestrator 取消前会把这部分并入累计 usage,不再丢失。 4. EOF 截断被当作成功完成(Medium):OpenAI 解析器现在跟踪是否见过 finish_reason,reader EOF 但既没有 [DONE] 也没见过 finish_reason 时按 未预期断连报错,不再把可能截断的部分内容当作完整答案持久化,与 Anthropic 解析器的对应处理保持一致。 5. 图片 token 估算严重失真(Medium):base64 字节数不再按 1:1 当 token 数—— 一张普通 100KB 照片按此前的估算会膨胀到十几万 token,直接撑爆未配置 contextWindow 模型(128K)的输入预算。改为按更贴近真实 provider 计费 量级、但仍保守的固定系数(40 字节/token)折算,明显缓解普通截图/照片 被误判超限的问题;精确的按图片宽高计费仍是已知的后续待办(需要在附件 加载阶段解码图片尺寸,超出本次改动范围)。 6. ephemeral 提前退出留下悬空 tool_call 协议状态(Medium):processStreamEphemeral 的 finish() 现在会把没有收到 tool_call_complete 就被提前 return()/throw() 打断的 toolCall 统一补成终态 cancelled,并补上配对的 tool 结果消息, 保证重放给 provider 的历史里 tool_call/tool_result 协议状态始终完整, 不会因为 provider 拒绝"assistant 消息里的 tool_call 缺少对应结果"而报错。 7. 模型 token 限制未校验(Low):AgentModelRepo.saveModel 落库前统一把 maxTokens/contextWindow 归一化为有限正整数(超出合理范围或非正数一律 视为未配置),作为存储边界的唯一校验点。getContextWindow 与 OpenAI/ Anthropic 的请求体构造也改为显式的正数校验,不再用 truthy 判断——负数 在 JS 里是 truthy,会绕过校验原样发给 provider 或让预算计算塌缩为 0。 已评估但本次未实现(记录在案,供后续跟进): - 消息/任务/附件/会话元数据的仓库层跨执行环境序列化(finding 3)与队列的 连接感知/重入策略(finding 4):Options 页面、定时任务与 Service Worker 分属不同 JS 执行上下文,进程内的 stackAsyncTask 队列无法覆盖跨上下文的 并发写入,真正的修复需要 Web Locks API(navigator.locks,跨上下文共享同 一把源级锁)覆盖仓库层所有写方法,并为"活跃 tool handler 内发起新操作" 设计明确的重入策略——这是一次影响面大、需要独立测试与产品决策(如 conv.clear() 在工具执行期间调用应该等待还是报错)的改动,仓促实现风险 高于收益,故未在本次改动中处理。 验证: - npx vitest run(3111 项全过,新增/调整测试覆盖 cancelling 阶段 attach、 finalizeCancelled 幂等、guard-Stop/max_iterations 取消复查、abort 时 usage 透传、EOF 截断报错、图片 token 估算、ephemeral 悬空 toolCall、 模型限制归一化) - npx tsc --noEmit / eslint / prettier --check 全部通过 - npx playwright test(agent-conversation/agent-error-handling/agent-chat/ agent-navigation/agent-provider,13 项全过) Co-Authored-By: Claude Sonnet 5 --- src/app/repo/agent_model.ts | 5 +- src/app/repo/opfs_repo.ts | 9 +- src/app/service/agent/core/agent.test.ts | 6 +- .../agent/core/context_elision.test.ts | 26 ++- src/app/service/agent/core/context_elision.ts | 34 +++- .../service/agent/core/model_context.test.ts | 46 ++++- src/app/service/agent/core/model_context.ts | 32 +++- .../agent/core/providers/anthropic.test.ts | 27 +++ .../service/agent/core/providers/anthropic.ts | 28 ++- .../agent/core/providers/openai.test.ts | 48 ++++- .../service/agent/core/providers/openai.ts | 38 +++- .../agent/service_worker/autocompact.test.ts | 6 +- .../agent/service_worker/background.test.ts | 58 +++++- .../background_session_manager.ts | 25 ++- .../service/agent/service_worker/chat.test.ts | 3 +- .../service/agent/service_worker/llm.test.ts | 20 +- .../agent/service_worker/llm_client.ts | 22 ++- .../agent/service_worker/test-helpers.ts | 2 +- .../tool_loop_orchestrator_base.ts | 174 ++++++++++++------ .../service/content/gm_api/cat_agent.test.ts | 38 ++++ src/app/service/content/gm_api/cat_agent.ts | 30 ++- 21 files changed, 541 insertions(+), 136 deletions(-) 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 89199df0b..0104be3fd 100644 --- a/src/app/repo/opfs_repo.ts +++ b/src/app/repo/opfs_repo.ts @@ -59,8 +59,13 @@ export class OPFSRepo { // 写入 JSON 文件。 // OPFS 的 createWritable() 本身是事务性的:write() 写入的是临时副本,只有 close() 成功 // 才会原子替换原文件;调用方持有的旧内容在此之前始终完整可读。 - // 传入 signal 时,若在 close() 落定前已 abort,则改为 writable.abort() 放弃这次写入, - // 避免"取消已经发生,但覆盖性写入仍然提交"的竞态(见 finding 4)。 + // 传入 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, diff --git a/src/app/service/agent/core/agent.test.ts b/src/app/service/agent/core/agent.test.ts index 6b692e923..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', ]); @@ -547,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 { @@ -568,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`); diff --git a/src/app/service/agent/core/context_elision.test.ts b/src/app/service/agent/core/context_elision.test.ts index 4a156cc87..e603e48f3 100644 --- a/src/app/service/agent/core/context_elision.test.ts +++ b/src/app/service/agent/core/context_elision.test.ts @@ -149,11 +149,14 @@ describe("上下文预算估算与裁剪", () => { { role: "assistant", content: "已看到图片" }, { role: "user", content: "继续" }, ]; - const sizes = new Map([["old", 6000]]); - // 估算以字节为单位再折算为保守 token 数(见 CONSERVATIVE_BYTES_PER_TOKEN), - // 因此不能直接与原始字节数 6000 比较;改为验证 base64 展开确实被计入 - // —— 若只是把原始字节数朴素折半(3000),结果不会超过它。 - expect(estimateRequestTokens(messages, [], sizes, VISION_MODEL)).toBeGreaterThan(3000); + // 用较大的真实照片量级(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"); @@ -198,6 +201,19 @@ describe("上下文预算估算与裁剪", () => { 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 的按消息缓存不应产生陈旧结果", () => { diff --git a/src/app/service/agent/core/context_elision.ts b/src/app/service/agent/core/context_elision.ts index ae4e93efa..1568e542b 100644 --- a/src/app/service/agent/core/context_elision.ts +++ b/src/app/service/agent/core/context_elision.ts @@ -70,6 +70,15 @@ function getStableContentBytes(message: { role: string; content: unknown; toolCa 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 官方计数器,引入新依赖超出当前改动范围)。分两部分独立估算, @@ -78,10 +87,9 @@ function getStableContentBytes(message: { role: string; content: unknown; toolCa * - 文本 + JSON 结构(messages/tools/toolCalls):按 UTF-8 字节数 / CONSERVATIVE_BYTES_PER_TOKEN * 保守折算。这仍然是启发式而非保证:高熵内容或极端 tokenizer 差异下仍可能被低估, * 调用方(preflight 预算检查、elideUntilWithinBudget)应把结果当作"大致上界"而非精确值。 - * - vision 图片:provider 的图片计费与 base64 字节数 / 2 没有对应关系(如 OpenAI 按分块、 - * Anthropic 按 宽×高/750),本仓库没有图片宽高元数据,无法套用具体公式;因此不对这部分 - * 字节数做任何折算(即按 1 字节 = 1 token 计),宁可在图片场景下偏保守(可能拒绝部分本可 - * 容纳的请求),也不假装能精确换算图片 token 从而低估导致请求超限。 + * - vision 图片:按 IMAGE_CONSERVATIVE_BYTES_PER_TOKEN 折算(见上方常量注释),而不是直接把 + * base64 字节数当 token 数——后者会让一张普通照片的估算膨胀到几万 token,超出未配置 + * contextWindow 的模型(如 128K)的输入预算,把正常截图/照片当作"超出上下文"拒绝掉。 * * 只对 provider 实际会内联展开为 base64 的块计入二进制字节: * 当前仅 vision 模型的 image 块会被 resolveAttachments 加载;file/audio 及非 vision 模型的 @@ -94,7 +102,7 @@ export function estimateRequestTokens( model?: AgentModelConfig ): number { const hasVision = model ? supportsVision(model) : false; - const attachmentBytes = messages.reduce((sum, message) => { + const attachmentTokens = messages.reduce((sum, message) => { if (!Array.isArray(message.content)) return sum; return ( sum + @@ -104,13 +112,19 @@ export function estimateRequestTokens( if (block.type === "file" || block.type === "audio" || !hasVision) return blockSum; const size = attachmentSizes?.get(block.attachmentId); if (size == null) { - return blockSum + new TextEncoder().encode(imageBlockFallbackText(block)).byteLength; + // 附件大小未知时降级为纯文本描述——这部分本身就是文本,按文本换算系数折算, + // 不能套用图片换算系数(那是给真实二进制图片数据用的) + return ( + blockSum + + Math.ceil(new TextEncoder().encode(imageBlockFallbackText(block)).byteLength / CONSERVATIVE_BYTES_PER_TOKEN) + ); } - return blockSum + (Math.ceil(size / 3) * 4 + 128); + const base64Bytes = Math.ceil(size / 3) * 4 + 128; + return blockSum + Math.ceil(base64Bytes / IMAGE_CONSERVATIVE_BYTES_PER_TOKEN); }, 0) ); }, 0); - if (!Number.isFinite(attachmentBytes)) return Number.POSITIVE_INFINITY; + if (!Number.isFinite(attachmentTokens)) return Number.POSITIVE_INFINITY; let bytes = 0; for (const message of messages) { @@ -122,8 +136,8 @@ export function estimateRequestTokens( if (tools && tools.length > 0) { bytes += new TextEncoder().encode(JSON.stringify(tools)).byteLength; } - // 文本/JSON 部分按保守系数折算;图片字节数不折算(见函数注释),两者独立相加 - return Math.ceil(bytes / CONSERVATIVE_BYTES_PER_TOKEN) + attachmentBytes; + // 文本/JSON 部分按文本换算系数折算;图片部分已经在上面按图片换算系数折算成 token,两者相加 + return Math.ceil(bytes / CONSERVATIVE_BYTES_PER_TOKEN) + attachmentTokens; } /** 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 f29dddf67..0556695f1 100644 --- a/src/app/service/agent/core/model_context.ts +++ b/src/app/service/agent/core/model_context.ts @@ -48,7 +48,11 @@ 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; @@ -85,3 +89,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/providers/anthropic.test.ts b/src/app/service/agent/core/providers/anthropic.test.ts index 060741433..d905fbea0 100644 --- a/src/app/service/agent/core/providers/anthropic.test.ts +++ b/src/app/service/agent/core/providers/anthropic.test.ts @@ -492,6 +492,33 @@ describe("parseAnthropicStream", () => { 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([ diff --git a/src/app/service/agent/core/providers/anthropic.ts b/src/app/service/agent/core/providers/anthropic.ts index 517c687f8..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, @@ -138,7 +139,9 @@ export function buildAnthropicRequest( 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) => ({ @@ -345,13 +348,22 @@ export function parseAnthropicStream( 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" }); - } - }); + ) + .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/openai.test.ts b/src/app/service/agent/core/providers/openai.test.ts index cde5c51c2..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', ]); @@ -250,6 +250,34 @@ describe("parseOpenAIStream", () => { 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 () => { @@ -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/service_worker/autocompact.test.ts b/src/app/service/agent/service_worker/autocompact.test.ts index 5905100a3..48fb8a4b2 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`, ]); } @@ -202,7 +202,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`, ]) ); @@ -235,7 +235,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 f9799483d..e989701f4 100644 --- a/src/app/service/agent/service_worker/background.test.ts +++ b/src/app/service/agent/service_worker/background.test.ts @@ -4,14 +4,15 @@ import { createTestService, makeTextResponse, makeSSEResponse, createRunningConv // 与 llm.test.ts 中的同名辅助函数一致:构造带 tool_call 的 OpenAI SSE 响应 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); } @@ -327,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" }); @@ -554,7 +604,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) { 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 3d111b1b1..be75e934c 100644 --- a/src/app/service/agent/service_worker/background_session_manager.ts +++ b/src/app/service/agent/service_worker/background_session_manager.ts @@ -170,12 +170,17 @@ export class BackgroundSessionManager { rc.abortController.abort(); } - // 执行方在 abort 落定、promise 真正 settle 后调用,把 cancelling 收敛为终态。 + // 执行方在 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 !== "cancelling") return; - rc.status = "error"; + if (rc.status === "running") return; + if (rc.status === "cancelling") rc.status = "error"; this.cleanupIfDone(conversationId); } @@ -211,14 +216,18 @@ export class BackgroundSessionManager { : undefined, pendingAskUser: rc.pendingAskUser, tasks: rc.tasks, - // cancelling 对外等同于终态:晚到的重连者不会再收到任何后续广播(见 stop/finalizeCancelled), - // 应立即按已结束处理,而不是误报为 running 导致 UI 一直转圈等待 - status: rc.status === "cancelling" ? "error" : 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; } diff --git a/src/app/service/agent/service_worker/chat.test.ts b/src/app/service/agent/service_worker/chat.test.ts index a89a54559..73ca244a7 100644 --- a/src/app/service/agent/service_worker/chat.test.ts +++ b/src/app/service/agent/service_worker/chat.test.ts @@ -583,7 +583,7 @@ describe("scriptToolCallback 的 abort/disconnect/超时处理", () => { 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)}}}]}}]}\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; @@ -597,6 +597,7 @@ describe("scriptToolCallback 的 abort/disconnect/超时处理", () => { if (i < chunks.length) return { done: false, value: encoder.encode(chunks[i++]) }; return { done: true, value: undefined }; }, + cancel: async () => {}, }; }, }, diff --git a/src/app/service/agent/service_worker/llm.test.ts b/src/app/service/agent/service_worker/llm.test.ts index d140928e0..d6193ff47 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,14 +350,15 @@ 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); } @@ -368,14 +369,15 @@ describe("callLLMWithToolLoop 工具调用循环", () => { promptTokens: number ): 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":${promptTokens},"completion_tokens":5}}\n\n`); return makeSSEResponse(chunks); } diff --git a/src/app/service/agent/service_worker/llm_client.ts b/src/app/service/agent/service_worker/llm_client.ts index d376666b3..843b7e45d 100644 --- a/src/app/service/agent/service_worker/llm_client.ts +++ b/src/app/service/agent/service_worker/llm_client.ts @@ -201,12 +201,22 @@ 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[] = []; + const orphanedIds: string[] = []; for (const pending of pendingImageSaves) { + if (settled) break; try { await this.chatRepo.saveAttachment(pending.block.attachmentId, pending.data); + if (settled) { + orphanedIds.push(pending.block.attachmentId); + break; + } savedBlocks.push(pending.block); // 转发不含 data 的 content_block_complete 事件给 UI sendEvent({ type: "content_block_complete", block: pending.block }); @@ -219,13 +229,17 @@ 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); + if (settled) { + orphanedIds.push(blockId); + break; + } const block: ContentBlock = { type: "image", attachmentId: blockId, @@ -244,6 +258,10 @@ export class LLMClient { content = cleanedContent.replace(/\n{3,}/g, "\n\n").trim(); } + if (orphanedIds.length > 0) { + await Promise.all(orphanedIds.map((id) => this.chatRepo.deleteAttachment(id).catch(() => {}))); + } + return savedBlocks.length > 0 ? savedBlocks : undefined; }; diff --git a/src/app/service/agent/service_worker/test-helpers.ts b/src/app/service/agent/service_worker/test-helpers.ts index 253c9c69e..7077cfe4a 100644 --- a/src/app/service/agent/service_worker/test-helpers.ts +++ b/src/app/service/agent/service_worker/test-helpers.ts @@ -203,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_base.ts b/src/app/service/agent/service_worker/tool_loop_orchestrator_base.ts index 9a6d5651f..af33f6fd4 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator_base.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator_base.ts @@ -111,7 +111,9 @@ export class ToolLoopOrchestrator { private chatRepo: AgentChatRepo ) {} - /** 上下文即使裁剪到底也无法容纳下一次请求时,落库 + 通知 UI + (可选)抛出结构化错误。 */ + /** 上下文即使裁剪到底也无法容纳下一次请求时,落库 + 通知 UI + (可选)抛出结构化错误。 + * 落库失败不能阻塞事件发送(见 finding 5:事件投递不应依赖持久化成功); + * 若此时已被 Stop,取消优先于 context_too_large,改走统一的取消终态化路径。 */ private async emitContextTooLarge( conversationId: string | undefined, totalUsage: { @@ -122,8 +124,13 @@ export class ToolLoopOrchestrator { }, 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, @@ -131,17 +138,21 @@ export class ToolLoopOrchestrator { conversationId, }); if (conversationId) { - await this.chatRepo.appendMessage({ - id: uuidv4(), - conversationId, - role: "assistant", - content: "", - error: error.message, - errorCode: error.errorCode, - usage: totalUsage, - durationMs: error.durationMs, - createtime: Date.now(), - }); + 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 { + // 持久化失败不阻塞终态事件发送 + } } sendEvent({ type: "error", @@ -153,7 +164,8 @@ export class ToolLoopOrchestrator { if (throwOnTerminalError) throw error; } - /** 取消(stop)落定时的统一终态化:持久化一条终态记录 + 发送唯一的终态事件,携带累计 usage/耗时。 */ + /** 取消(stop)落定时的统一终态化:持久化一条终态记录 + 发送唯一的终态事件,携带累计 usage/耗时。 + * 落库失败不能阻塞事件发送,否则客户端永远收不到终态事件(见 finding 5)。 */ private async emitCancelled( conversationId: string | undefined, totalUsage: { @@ -167,17 +179,21 @@ export class ToolLoopOrchestrator { ): Promise { const durationMs = Date.now() - startTime; if (conversationId) { - await this.chatRepo.appendMessage({ - id: uuidv4(), - conversationId, - role: "assistant", - content: "", - error: "Conversation cancelled", - errorCode: "cancelled", - usage: totalUsage, - durationMs, - createtime: Date.now(), - }); + 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", @@ -259,7 +275,14 @@ export class ToolLoopOrchestrator { model ); if (!withinBudget) { - await this.emitContextTooLarge(conversationId, totalUsage, startTime, sendEvent, throwOnTerminalError); + await this.emitContextTooLarge( + conversationId, + totalUsage, + startTime, + sendEvent, + signal, + throwOnTerminalError + ); return; } } @@ -300,7 +323,14 @@ export class ToolLoopOrchestrator { model ); if (!withinBudget) { - await this.emitContextTooLarge(conversationId, totalUsage, startTime, sendEvent, throwOnTerminalError); + await this.emitContextTooLarge( + conversationId, + totalUsage, + startTime, + sendEvent, + signal, + throwOnTerminalError + ); return; } } @@ -322,6 +352,16 @@ export class ToolLoopOrchestrator { // 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; } @@ -570,15 +610,24 @@ export class ToolLoopOrchestrator { if (answer.trim().toLowerCase() === GUARD_STOP_ANSWER.toLowerCase()) { const durationMs = Date.now() - startTime; if (conversationId) { - await this.chatRepo.appendMessage({ - id: uuidv4(), - conversationId, - role: "assistant", - content: t("agent:chat_guard_stopped_message"), - usage: totalUsage, - durationMs, - createtime: Date.now(), - }); + 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; @@ -615,16 +664,20 @@ export class ToolLoopOrchestrator { // 没有 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(), - }); + try { + await this.chatRepo.appendMessage({ + id: uuidv4(), + conversationId, + role: "assistant", + content: buildMessageContent(), + thinking: result.thinking ? { content: result.thinking } : undefined, + usage: totalUsage, + durationMs, + createtime: Date.now(), + }); + } catch { + // 持久化失败不阻塞终态事件发送 + } } // 持久化期间也可能已被 stop:内容已经落库(不丢失),但终态事件必须反映取消, @@ -643,17 +696,26 @@ export class ToolLoopOrchestrator { const durationMs = Date.now() - startTime; const maxIterMsg = `Tool calling loop exceeded maximum iterations (${maxIterations})`; if (conversationId) { - await this.chatRepo.appendMessage({ - id: uuidv4(), - conversationId, - role: "assistant", - content: "", - error: maxIterMsg, - errorCode: "max_iterations", - usage: totalUsage, - durationMs, - createtime: Date.now(), - }); + 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", 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 fa69d4946..37b877036 100644 --- a/src/app/service/content/gm_api/cat_agent.test.ts +++ b/src/app/service/content/gm_api/cat_agent.test.ts @@ -498,6 +498,44 @@ describe("ConversationInstance tool_call_complete / new_message 重建历史", ( 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(); + }); }); // ---- errorCode 透传测试 ---- diff --git a/src/app/service/content/gm_api/cat_agent.ts b/src/app/service/content/gm_api/cat_agent.ts index 5dcaaab85..8db94acd8 100644 --- a/src/app/service/content/gm_api/cat_agent.ts +++ b/src/app/service/content/gm_api/cat_agent.ts @@ -452,19 +452,31 @@ function processStreamEphemeral( 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: toolCalls.length ? toolCalls.map(cloneToolCall) : undefined, + toolCalls: finalized.length ? finalized : undefined, }); - for (const toolCall of toolCalls) { - if (toolCall.result !== undefined) { - this.messageHistory.push({ - role: "tool", - content: toolCall.result, - toolCallId: toolCall.id, - }); - } + for (const toolCall of finalized) { + this.messageHistory.push({ + role: "tool", + content: toolCall.result!, + toolCallId: toolCall.id, + }); } } text = ""; From cd3c79dba4ca1c93a3acb156069f77a800496f15 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 13 Jul 2026 08:11:13 +0900 Subject: [PATCH 32/38] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=8F=96=E6=B6=88=20abort=20=E6=82=AC=E6=8C=82=E3=80=81?= =?UTF-8?q?=E7=BB=88=E6=80=81=E5=8F=8C=E4=B8=BB=E4=BD=93=E7=AB=9E=E6=80=81?= =?UTF-8?q?=E3=80=81compact=20=E7=A0=B4=E5=9D=8F=E6=80=A7=E8=A6=86?= =?UTF-8?q?=E5=86=99=E4=B8=8E=E5=B9=B6=E5=8F=91=E5=86=99=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. SSE reader 正常终态提前退出未释放(Medium):readSSEStream() 在 onEvent 返回 true 提前 return 时不再遗留未读完的 body/reader lock——finally 里 识别这种提前退出场景并主动 cancel reader,避免 Anthropic 在 message_delta 就终止本地处理导致连接资源占用到 GC。 2. LLMClient 的 abort 安全网抢占带 usage 的真实 reject(Medium):signal abort 时不再立即 reject,而是延迟到下一个宏任务,让 parseStream 自身 (已知会携带部分 usage)的 reject 有机会先落定;同时 case "error" 改为 保留 usage/errorCode/durationMs 等完整字段,不再转成裸 Error 丢信息。 3. 生成图片的孤儿附件清理遗漏(Medium):LLMClient 的图片保存流程原来只 在"取消发生时正在保存的那一个"进入清理列表,更早已经保存成功的图片 遗漏不清理。改为记录本轮所有成功落盘的附件 id,一旦发现已经因取消 而 settled,统一清理这一轮的全部附件,不再遗漏。 4. emitContextTooLarge 落库后未复查取消(Medium):持久化占位错误消息之后 补上信号复查,Stop 落在持久化期间时改走统一的取消终态化路径,不再对外 报告/抛出 context_too_large。 5. 自动 compact 落盘后未复查取消(High):saveMessages() 成功后补上信号 复查,取消落在写盘之后、覆盖内存/广播 compact_done 之前时中止,不再 在 Stop 之后仍报告自动压缩"成功"。 6. Options UI 发现/生命周期问题(High): - listIds() 现在把 cancelling 也纳入发现列表,页面刷新/重连时才能 继续 attach 等到真正携带取消原因/usage 的终态事件。 - stopGeneration() 不再立即断开连接、也不再立即把 isStreaming 置 false——两者都留给真正的终态事件(或连接意外断开)触发,避免"连接断开 但 done 回调未触发"的兜底逻辑提前处理排队消息。 - sendMessage()/attachToConversation() 的 onMessage 现在在 abort 之后 仍放行终态事件(只抑制中间的流式增量),确保 onDone 被正确调用。 - handleStop() 不再自行处理排队消息或重新加载历史,改由 onDone 在 真正终态落定后统一处理,避免排队消息在旧会话仍占用中时被拒绝、 且从未持久化就丢失。 7. executeTools 在批次内不感知 settle(Medium):cat_agent.ts 的 executeTools 现在在每个 handler 调用前检查连接是否已 settle(Stop/ 脚本工具超时/断开),一旦发现直接把剩余 toolCall 标记为取消,不再 继续串行执行,避免与后续新批次的 handler 产生副作用重叠。 8. 最终成功回复静默丢失持久化失败(High):正常完成分支的 appendMessage 现在有限重试(3 次,退避 200ms/400ms),仍失败则改发结构化 error (errorCode: persist_failed)而不是继续对外报告 done——done 隐含"回复 已保存"的承诺,静默吞掉写入失败会让 UI 刷新后回复消失。 9. 输出 token 预留为 0 的隐患(Medium):getReservedOutputTokens 对未显式 配置 maxTokens 的 OpenAI 兼容模型不再预留 0——请求体这种情况下会直接 省略 max_tokens 字段,provider 侧会套用它自己的非零默认输出上限;改为 统一用一个有记录的保守默认值兜底,避免输入预算把整个 contextWindow 都算给输入,导致 输入+输出 超出真实上限。 10. 工具附件保存未感知取消(Medium):tool_registry.ts 的 saveAttachments 现在接受 signal,在每个附件写入前检查;内置工具与脚本工具两条路径的 调用点都已传入 signal,abort 时停止继续写入并把该 toolCall 标记为 error,不再在 Stop 之后继续写多个文件。 评估后本次未处理(记录在案,与上一轮跟进项一致):仓库层跨执行环境的 事务性写入(Web Locks + CAS)与队列的连接感知/重入策略——Options 页面、 定时任务与 Service Worker 分属不同 JS 执行上下文,真正的修复需要 navigator.locks 覆盖仓库层所有写方法并对"活跃 tool handler 内发起新 操作"(如工具执行期间调用 conv.clear())设计明确的重入策略,属于独立的 一轮改动,不在本次范围内。task 工具的持久化回调取消感知同理未处理。 验证: - npx vitest run(3115 项全过,新增/调整测试覆盖 SSE 提前退出释放 reader、 abort 安全网延迟、图片孤儿附件全量清理、compact 落盘后复查、cancelling 发现列表、stop 后终态事件放行、executeTools 批次内 settle 检测、 持久化失败不报 done、输出预留非零、附件保存取消) - npx tsc --noEmit / eslint / prettier --check 全部通过 - npx playwright test(agent-conversation/agent-error-handling/agent-chat/ agent-navigation/agent-provider,13 项全过) Co-Authored-By: Claude Sonnet 5 --- src/app/service/agent/core/model_context.ts | 11 +++- .../agent/core/providers/content_utils.ts | 16 ++++- .../service/agent/core/tool_registry.test.ts | 33 ++++++++++ src/app/service/agent/core/tool_registry.ts | 27 ++++++-- .../agent/service_worker/autocompact.test.ts | 5 +- .../agent/service_worker/background.test.ts | 4 +- .../background_session_manager.ts | 5 +- .../agent/service_worker/compact_service.ts | 4 ++ .../service/agent/service_worker/llm.test.ts | 43 +++++++++++- .../agent/service_worker/llm_client.ts | 40 ++++++++---- .../tool_loop_orchestrator.test.ts | 10 ++- .../tool_loop_orchestrator_base.ts | 57 ++++++++++++---- .../service/content/gm_api/cat_agent.test.ts | 59 +++++++++++++++++ src/app/service/content/gm_api/cat_agent.ts | 18 ++++- .../options/routes/Agent/Chat/ChatArea.tsx | 12 ++-- .../options/routes/Agent/Chat/hooks.test.ts | 65 ++++++++++++++++++- src/pages/options/routes/Agent/Chat/hooks.ts | 31 +++++---- 17 files changed, 374 insertions(+), 66 deletions(-) diff --git a/src/app/service/agent/core/model_context.ts b/src/app/service/agent/core/model_context.ts index 0556695f1..03f898c88 100644 --- a/src/app/service/agent/core/model_context.ts +++ b/src/app/service/agent/core/model_context.ts @@ -60,14 +60,21 @@ export function getContextWindow(config: { model: string; contextWindow?: number return DEFAULT_CONTEXT_WINDOW; } -/** 获取 provider 实际会请求的最大输出 token 数。 */ +/** + * 获取 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 : config.provider === "anthropic" ? DEFAULT_ANTHROPIC_MAX_TOKENS : 0; + const requested = configured > 0 ? configured : DEFAULT_ANTHROPIC_MAX_TOKENS; return Math.min(contextWindow, requested); } diff --git a/src/app/service/agent/core/providers/content_utils.ts b/src/app/service/agent/core/providers/content_utils.ts index 95c4d9b75..ea96ec582 100644 --- a/src/app/service/agent/core/providers/content_utils.ts +++ b/src/app/service/agent/core/providers/content_utils.ts @@ -72,6 +72,10 @@ export async function readSSEStream( }; 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(); @@ -82,7 +86,10 @@ 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) @@ -93,5 +100,12 @@ export async function readSSEStream( 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/tool_registry.test.ts b/src/app/service/agent/core/tool_registry.test.ts index e73287212..d70cd215c 100644 --- a/src/app/service/agent/core/tool_registry.test.ts +++ b/src/app/service/agent/core/tool_registry.test.ts @@ -444,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 528eaeeb4..2a24e25f9 100644 --- a/src/app/service/agent/core/tool_registry.ts +++ b/src/app/service/agent/core/tool_registry.ts @@ -236,7 +236,7 @@ export class ToolRegistry implements ToolExecutorLike { // 检查是否带附件或子代理详情 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 }; @@ -269,11 +269,22 @@ export class ToolRegistry implements ToolExecutorLike { try { const parsed = JSON.parse(sr.result); if (isToolResultWithAttachments(parsed)) { - const attachments = await this.saveAttachments(parsed.attachments); + const attachments = await this.saveAttachments(parsed.attachments, signal); results.push({ id: sr.id, result: parsed.content, attachments, error: sr.error }); continue; } - } catch { + } 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; + } // 不是 JSON 或不是结构化结果,按原始字符串处理 } results.push({ id: sr.id, result: sr.result, error: sr.error }); @@ -299,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/service_worker/autocompact.test.ts b/src/app/service/agent/service_worker/autocompact.test.ts index 48fb8a4b2..f2718563f 100644 --- a/src/app/service/agent/service_worker/autocompact.test.ts +++ b/src/app/service/agent/service_worker/autocompact.test.ts @@ -109,7 +109,10 @@ describe("Compact 功能", () => { apiBaseUrl: "", apiKey: "", model: "gpt-4o", - contextWindow: 10000, + // 提高到 40000:getReservedOutputTokens 现在对未显式配置 maxTokens 的 OpenAI 兼容模型 + // 也预留一个非零默认输出额度(见 model_context.ts,finding 11),10000 的窗口在扣除 + // 预留额度后输入预算会塌缩到 0,这里相应放宽窗口,保留测试原本想验证的"裁剪旧工具结果"场景 + contextWindow: 40000, }); mockRepo.listConversations.mockResolvedValue([BASE_CONV]); const messages: any[] = []; diff --git a/src/app/service/agent/service_worker/background.test.ts b/src/app/service/agent/service_worker/background.test.ts index e989701f4..9f1ef4ac5 100644 --- a/src/app/service/agent/service_worker/background.test.ts +++ b/src/app/service/agent/service_worker/background.test.ts @@ -477,7 +477,9 @@ describe("handleAttachToConversation 重连逻辑", () => { expect(rc.pendingAskUser).toBeUndefined(); expect(rc.askResolvers.size).toBe(0); expect((service as any).bgSessionManager.has("conv-stop")).toBe(true); - expect(service.getRunningConversationIds()).not.toContain("conv-stop"); + // cancelling 现在也纳入发现列表:UI 侧(Options 页面刷新/重连)需要据此判断是否 + // 应该继续 attach 等待真正的终态事件,而不是误判为"已经不在运行"(见 finding 6) + expect(service.getRunningConversationIds()).toContain("conv-stop"); // stop() 本身不再广播任何终态事件:唯一的终态事件来自执行方(orchestrator 的 // emitCancelled,走正常 sendEvent → updateStreamingState → broadcastEvent 路径), // 避免"先广播一条不带 usage 的事件,UI 断开后丢失后到的真实终态事件"的竞态 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 be75e934c..c363e661f 100644 --- a/src/app/service/agent/service_worker/background_session_manager.ts +++ b/src/app/service/agent/service_worker/background_session_manager.ts @@ -49,9 +49,12 @@ export class BackgroundSessionManager { this.runningConversations.delete(conversationId); } + // cancelling 也算在内:handleAttach() 已经支持对 cancelling 会话继续订阅直到真正的终态 + // 事件,但如果这里不把 cancelling 纳入发现列表,UI 侧(Options 页面刷新/重连)永远不会 + // 尝试 attach,也就永远等不到那条真正携带取消原因/usage 的终态事件(见 finding 6) listIds(): string[] { return Array.from(this.runningConversations.entries()) - .filter(([, conversation]) => conversation.status === "running") + .filter(([, conversation]) => conversation.status === "running" || conversation.status === "cancelling") .map(([conversationId]) => conversationId); } diff --git a/src/app/service/agent/service_worker/compact_service.ts b/src/app/service/agent/service_worker/compact_service.ts index 848b2e2ee..69b70491d 100644 --- a/src/app/service/agent/service_worker/compact_service.ts +++ b/src/app/service/agent/service_worker/compact_service.ts @@ -110,6 +110,10 @@ export class CompactService { }; 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; diff --git a/src/app/service/agent/service_worker/llm.test.ts b/src/app/service/agent/service_worker/llm.test.ts index d6193ff47..6339a16d8 100644 --- a/src/app/service/agent/service_worker/llm.test.ts +++ b/src/app/service/agent/service_worker/llm.test.ts @@ -590,7 +590,11 @@ describe("callLLMWithToolLoop 工具调用循环", () => { const { service, mockRepo, mockModelRepo } = createTestService(); const { sender } = createMockSender(); - // 使用较小的 contextWindow,便于用少量 prompt_tokens 触发裁剪阈值 + // 使用较小的 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", @@ -598,7 +602,7 @@ describe("callLLMWithToolLoop 工具调用循环", () => { apiBaseUrl: "", apiKey: "", model: "gpt-4o", - contextWindow: 100000, + contextWindow: 120000, }); // 使用两个不同名称的工具交替调用,避免触发 tool_call_guard 的重复调用检测 @@ -809,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 843b7e45d..b5941197c 100644 --- a/src/app/service/agent/service_worker/llm_client.ts +++ b/src/app/service/agent/service_worker/llm_client.ts @@ -143,7 +143,14 @@ export class LLMClient { signal.removeEventListener("abort", onAbortSafeguard); fn(); }; - const onAbortSafeguard = () => settleOnce(() => reject(new Error("Aborted"))); + // 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)); @@ -208,15 +215,17 @@ export class LLMClient { // 停止继续保存/发送,并清理这一轮已经落盘但用不上的附件(见 finding 6)。 const finalize = async () => { const savedBlocks: ContentBlock[] = []; - const orphanedIds: string[] = []; + // 本轮所有成功落盘的附件 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); - if (settled) { - orphanedIds.push(pending.block.attachmentId); - break; - } + 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 }); @@ -236,10 +245,8 @@ export class LLMClient { const blockId = generateAttachmentId(ext); try { await this.chatRepo.saveAttachment(blockId, dataUrl); - if (settled) { - orphanedIds.push(blockId); - break; - } + allSavedIds.push(blockId); + if (settled) break; const block: ContentBlock = { type: "image", attachmentId: blockId, @@ -258,8 +265,8 @@ export class LLMClient { content = cleanedContent.replace(/\n{3,}/g, "\n\n").trim(); } - if (orphanedIds.length > 0) { - await Promise.all(orphanedIds.map((id) => this.chatRepo.deleteAttachment(id).catch(() => {}))); + if (settled && allSavedIds.length > 0) { + await Promise.all(allSavedIds.map((id) => this.chatRepo.deleteAttachment(id).catch(() => {}))); } return savedBlocks.length > 0 ? savedBlocks : undefined; @@ -279,7 +286,14 @@ export class LLMClient { break; } case "error": - rejectOnce(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; } }; 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 index 0dec30e3c..ec28508f4 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator.test.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator.test.ts @@ -98,7 +98,10 @@ describe("ToolLoopOrchestrator 循环检测升级(loop-guard escalation)", ( await expect( orchestrator.callLLMWithToolLoop( - baseParams({ model: { ...MODEL, contextWindow: 10000 }, messages: [{ role: "user", content: "开始" }] }) + // 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", @@ -272,7 +275,10 @@ describe("ToolLoopOrchestrator 请求前预算检查(防止 tool 结果把下 function baseParams(overrides: Record = {}) { return { toolRegistry, - model: { ...MODEL, contextWindow: 1000 }, + // 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, 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 index af33f6fd4..053828ee1 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator_base.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator_base.ts @@ -154,6 +154,12 @@ export class ToolLoopOrchestrator { // 持久化失败不阻塞终态事件发送 } } + // 持久化期间也可能已被 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, @@ -661,22 +667,34 @@ export class ToolLoopOrchestrator { continue; } - // 没有 tool calls,对话结束 + // 没有 tool calls,对话结束。与取消/错误终态不同,done 对外承诺"回复已持久化"; + // UI 完成回调会重新从 OPFS 加载消息,静默吞掉写入失败仍报 done 会让回复看起来生成成功、 + // 刷新后又消失。这里有限重试几次,仍失败则改报结构化错误而不是假装成功(见 finding 10)。 const durationMs = Date.now() - startTime; + let persistFailed = false; if (conversationId) { - try { - await this.chatRepo.appendMessage({ - id: uuidv4(), - conversationId, - role: "assistant", - content: buildMessageContent(), - thinking: result.thinking ? { content: result.thinking } : undefined, - usage: totalUsage, - durationMs, - createtime: Date.now(), - }); - } catch { - // 持久化失败不阻塞终态事件发送 + 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)); + } + } } } @@ -687,6 +705,17 @@ export class ToolLoopOrchestrator { 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; 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 37b877036..319c51826 100644 --- a/src/app/service/content/gm_api/cat_agent.test.ts +++ b/src/app/service/content/gm_api/cat_agent.test.ts @@ -538,6 +538,65 @@ describe("ConversationInstance tool_call_complete / new_message 重建历史", ( }); }); +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 连接 diff --git a/src/app/service/content/gm_api/cat_agent.ts b/src/app/service/content/gm_api/cat_agent.ts index 8db94acd8..409677309 100644 --- a/src/app/service/content/gm_api/cat_agent.ts +++ b/src/app/service/content/gm_api/cat_agent.ts @@ -74,10 +74,22 @@ function resolveToolCall( async function executeTools( this: Instance, toolCalls: ToolCall[], - handlers: Map + 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; + } const handler = handlers.get(toolCall.name); if (!handler) { results.push({ @@ -165,7 +177,7 @@ function processChat(this: Instance, conn: MessageConnect, handlers: Map { if (message.action === "executeTools") { - const data = await executeTools.call(this, message.data, handlers); + const data = await executeTools.call(this, message.data, handlers, () => settled); // 工具函数执行期间连接可能已经因 Stop/脚本工具超时而 settle 并断开; // 断开后的连接 sendMessage 会抛错,且这里是异步回调,事件源不会 await/捕获它, // 会在用户脚本上下文里变成 unhandled rejection(见 finding 7) @@ -276,7 +288,7 @@ function processStream( conn.onMessage(async (message: any) => { if (message.action === "executeTools") { - const data = await executeTools.call(this, message.data, handlers); + const data = await executeTools.call(this, message.data, handlers, () => done); // 工具函数执行期间连接可能已经因 Stop/脚本工具超时而结束并断开; // 断开后的连接 sendMessage 会抛错,且这里是异步回调,事件源不会 await/捕获它, // 会在用户脚本上下文里变成 unhandled rejection(见 finding 7) diff --git a/src/pages/options/routes/Agent/Chat/ChatArea.tsx b/src/pages/options/routes/Agent/Chat/ChatArea.tsx index b7d9e59c1..93cc25b15 100644 --- a/src/pages/options/routes/Agent/Chat/ChatArea.tsx +++ b/src/pages/options/routes/Agent/Chat/ChatArea.tsx @@ -655,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) => { @@ -668,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; 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 cf05eda17..76a548a5d 100644 --- a/src/pages/options/routes/Agent/Chat/hooks.ts +++ b/src/pages/options/routes/Agent/Chat/hooks.ts @@ -154,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 可能已断开 - } } }, []); @@ -208,8 +205,13 @@ 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({ @@ -224,7 +226,7 @@ export function useStreamingChat() { 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; @@ -262,8 +264,13 @@ 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({ @@ -298,7 +305,7 @@ export function useStreamingChat() { return; } - if ((event.type === "done" || event.type === "error") && !("subAgent" in event && event.subAgent)) { + if (isTerminalEvent) { setIsStreaming(false); setAskUserPending(null); connRef.current = null; From d8360c427d0d49faf3cfc4eb213a9906d93279f3 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 16 Jul 2026 07:34:51 +0900 Subject: [PATCH 33/38] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=B0=8F=E4=B8=8A=E4=B8=8B=E6=96=87=E7=AA=97=E5=8F=A3=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E8=BE=93=E5=85=A5=E9=A2=84=E7=AE=97=E5=A1=8C=E7=BC=A9?= =?UTF-8?q?=E4=B8=BA=200=20=E5=AF=BC=E8=87=B4=E5=AF=B9=E8=AF=9D=E5=BF=85?= =?UTF-8?q?=E7=84=B6=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 未配置 maxTokens 时默认输出预留固定为 16384,叠加 10% 安全边际后, gpt-4(8K)/gpt-3.5(16K)/phi(16K) 及自定义小 contextWindow 模型的 getInputTokenBudget() 返回 0,preflight 预算检查对任何请求都判定 context_too_large,对话一开始就失败。默认预留改为不超过窗口的 1/4 (显式配置的 maxTokens 不受影响),并同步调整依赖旧预算公式常数的 autoCompact / 手动 compact 用例参数。 Co-Authored-By: Claude Fable 5 --- .../service/agent/core/model_context.test.ts | 29 +++++++++++++++++++ src/app/service/agent/core/model_context.ts | 6 +++- .../agent/service_worker/autocompact.test.ts | 8 ++--- .../tool_loop_orchestrator.test.ts | 8 ++--- 4 files changed, 42 insertions(+), 9 deletions(-) diff --git a/src/app/service/agent/core/model_context.test.ts b/src/app/service/agent/core/model_context.test.ts index 98def1806..18ce24859 100644 --- a/src/app/service/agent/core/model_context.test.ts +++ b/src/app/service/agent/core/model_context.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it } from "vitest"; import { getContextWindow, getInputTokenBudget, + getReservedOutputTokens, inferContextWindow, normalizeModelLimits, DEFAULT_CONTEXT_WINDOW, + DEFAULT_ANTHROPIC_MAX_TOKENS, } from "./model_context"; describe("getContextWindow", () => { @@ -71,6 +73,33 @@ describe("getContextWindow", () => { }); }); +describe("getReservedOutputTokens / getInputTokenBudget(小上下文窗口不应把输入预算压成 0)", () => { + it("未配置 maxTokens 时,大窗口模型仍保留默认输出预留", () => { + expect(getReservedOutputTokens({ model: "claude-3-haiku", provider: "anthropic" } as any)).toBe( + DEFAULT_ANTHROPIC_MAX_TOKENS + ); + expect(getReservedOutputTokens({ model: "gpt-4o", provider: "openai" } as any)).toBe(DEFAULT_ANTHROPIC_MAX_TOKENS); + }); + + it("未配置 maxTokens 时,默认输出预留不应吃掉小窗口模型的全部输入预算", () => { + // gpt-4 基础版窗口 8192:预留 16384 会让输入预算塌缩为 0,导致每次对话都直接报 context_too_large + for (const model of ["gpt-4-0613", "gpt-3.5-turbo", "phi-3-mini"]) { + const budget = getInputTokenBudget({ model, provider: "openai" } as any); + expect(budget, `${model} 的输入预算不应为 0`).toBeGreaterThan(0); + } + }); + + it("用户配置的小 contextWindow(本地小模型)同样保留可用的输入预算", () => { + const budget = getInputTokenBudget({ model: "my-local-model", contextWindow: 8192, provider: "openai" } as any); + expect(budget).toBeGreaterThan(0); + }); + + it("显式配置的 maxTokens 原样生效,不被默认值改写", () => { + expect(getReservedOutputTokens({ model: "gpt-4-0613", maxTokens: 4096, provider: "openai" } as any)).toBe(4096); + expect(getInputTokenBudget({ model: "gpt-4-0613", maxTokens: 4096, provider: "openai" } as any)).toBeGreaterThan(0); + }); +}); + describe("normalizeModelLimits(finding 10:存储边界统一归一化)", () => { it("负数 / 非有限数 / 超出合理范围一律归一化为 undefined", () => { expect(normalizeModelLimits({ maxTokens: -5, contextWindow: -1 })).toEqual({ diff --git a/src/app/service/agent/core/model_context.ts b/src/app/service/agent/core/model_context.ts index 03f898c88..31b258e10 100644 --- a/src/app/service/agent/core/model_context.ts +++ b/src/app/service/agent/core/model_context.ts @@ -74,7 +74,11 @@ export function getReservedOutputTokens(config: AgentModelConfig): number { 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; + // 未显式配置时,默认预留不能超过窗口的 1/4:否则小窗口模型(gpt-4 8K、gpt-3.5 16K、 + // 本地小模型等)会被 16384 的默认预留 + 安全边际吃掉全部输入预算, + // getInputTokenBudget() 塌缩为 0,每次对话都直接报 context_too_large。 + const requested = + configured > 0 ? configured : Math.min(DEFAULT_ANTHROPIC_MAX_TOKENS, Math.max(1, Math.floor(contextWindow / 4))); return Math.min(contextWindow, requested); } diff --git a/src/app/service/agent/service_worker/autocompact.test.ts b/src/app/service/agent/service_worker/autocompact.test.ts index f2718563f..c6060ee97 100644 --- a/src/app/service/agent/service_worker/autocompact.test.ts +++ b/src/app/service/agent/service_worker/autocompact.test.ts @@ -109,9 +109,9 @@ describe("Compact 功能", () => { apiBaseUrl: "", apiKey: "", model: "gpt-4o", - // 提高到 40000:getReservedOutputTokens 现在对未显式配置 maxTokens 的 OpenAI 兼容模型 - // 也预留一个非零默认输出额度(见 model_context.ts,finding 11),10000 的窗口在扣除 - // 预留额度后输入预算会塌缩到 0,这里相应放宽窗口,保留测试原本想验证的"裁剪旧工具结果"场景 + // 40000 窗口 + 未配置 maxTokens:默认输出预留 min(16384, 窗口/4)=10000(见 model_context.ts), + // 输入预算 = 40000 - 10000 - 4000(10% 安全边际) = 26000 token;下面 6 条约 7200 token 的 + // 工具结果远超预算,保证"裁剪旧工具结果"场景稳定触发 contextWindow: 40000, }); mockRepo.listConversations.mockResolvedValue([BASE_CONV]); @@ -129,7 +129,7 @@ describe("Compact 功能", () => { id: `t${i}`, conversationId: "conv-1", role: "tool", - content: "工具结果".repeat(500), + content: "工具结果".repeat(1200), toolCallId: `tc${i}`, createtime: i, }); 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 index ec28508f4..d655b6a27 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator.test.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator.test.ts @@ -98,10 +98,10 @@ describe("ToolLoopOrchestrator 循环检测升级(loop-guard escalation)", ( 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: "开始" }] }) + // 16000:未显式配置 maxTokens 时默认输出预留为 min(16384, 窗口/4)(见 model_context.ts), + // 输入预算 = 16000 - 4000(输出预留) - 1600(10% 安全边际) = 10400, + // 9000/10400 ≈ 0.87 ≥ 0.8,保留测试原本要验证的 autoCompact 触发场景 + baseParams({ model: { ...MODEL, contextWindow: 16000 }, messages: [{ role: "user", content: "开始" }] }) ) ).rejects.toMatchObject({ message: "compact failed", From f1841ad4249dc4f82bf5625b600807ba22e65479 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 16 Jul 2026 07:35:15 +0900 Subject: [PATCH 34/38] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=E5=90=88=E5=B9=B6?= =?UTF-8?q?=20ToolLoopOrchestrator=20=E5=8C=85=E8=A3=85=E5=B1=82=E4=B8=8E?= =?UTF-8?q?=E5=9F=BA=E7=B1=BB=E4=B8=BA=E5=8D=95=E4=B8=80=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tool_loop_orchestrator.ts 原本是对 _base 的包装类:每次调用重新 new 基类 实例,并无条件用 getInputTokenBudget 覆盖调用方传入的 inputTokenBudget, 基类中 inputTokenBudget === undefined 的分支在生产路径上永远不可达。 合并为单文件单类:inputTokenBudget 未传入时在循环内部按模型推导, 删除 tool_loop_orchestrator_base.ts(模块路径与公开导出保持不变)。 Co-Authored-By: Claude Fable 5 --- .../agent/core/context_elision.test.ts | 2 +- .../background_session_manager.ts | 4 +- .../service_worker/tool_loop_orchestrator.ts | 760 ++++++++++++++++- .../tool_loop_orchestrator_base.ts | 766 ------------------ 4 files changed, 756 insertions(+), 776 deletions(-) delete mode 100644 src/app/service/agent/service_worker/tool_loop_orchestrator_base.ts diff --git a/src/app/service/agent/core/context_elision.test.ts b/src/app/service/agent/core/context_elision.test.ts index e603e48f3..913b28c34 100644 --- a/src/app/service/agent/core/context_elision.test.ts +++ b/src/app/service/agent/core/context_elision.test.ts @@ -258,7 +258,7 @@ describe("estimateRequestTokens 的按消息缓存不应产生陈旧结果", () const messages: ChatRequest["messages"] = [{ role: "assistant", content: "", toolCalls: [toolCall] }]; const runningEstimate = estimateRequestTokens(messages); - // 工具执行完成后原地回写 status(tool_loop_orchestrator_base.ts 的 applyToolUpdates 同款操作) + // 工具执行完成后原地回写 status(tool_loop_orchestrator.ts 的 applyToolUpdates 同款操作) toolCall.status = "completed"; toolCall.result = "a longer completed result string that changes the byte size"; const completedEstimate = estimateRequestTokens(messages); 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 c363e661f..5056fe3a7 100644 --- a/src/app/service/agent/service_worker/background_session_manager.ts +++ b/src/app/service/agent/service_worker/background_session_manager.ts @@ -157,7 +157,7 @@ export class BackgroundSessionManager { // 停止后台会话:仅置为 cancelling(占用态,阻止同 ID 会话被过早顶替)并 abort, // 不在这里广播终态事件。终态事件的唯一来源是执行方(orchestrator 的 emitCancelled, - // 见 tool_loop_orchestrator_base.ts)在 promise 真正落定后发出的那一条——它携带完整的 + // 见 tool_loop_orchestrator.ts)在 promise 真正落定后发出的那一条——它携带完整的 // 累计 usage/耗时。此前 stop() 自己先广播一条不带 usage 的 error:cancelled,会导致 UI // 连接在收到 orchestrator 发出的、携带真实 usage 的终态事件之前就已断开丢弃它, // 也会让 finalizeCancelled() 因为 status 已被后到的 error 事件提前改写而永远不触发清理。 @@ -220,7 +220,7 @@ export class BackgroundSessionManager { pendingAskUser: rc.pendingAskUser, tasks: rc.tasks, // cancelling 还没有产生真正携带取消原因/usage/耗时的终态事件(那条事件由 orchestrator 的 - // emitCancelled() 在 promise 落定后才广播,见 tool_loop_orchestrator_base.ts)。 + // emitCancelled() 在 promise 落定后才广播,见 tool_loop_orchestrator.ts)。 // 之前这里直接报 "error" 会让客户端立即断开重载,永远收不到那条真正完整的终态事件, // 也可能在取消记录落库前就重载;改为对外仍按 "running" 处理并继续订阅 listener, // 客户端会一直等到下面广播的那条真正的终态事件再断开。 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 41def8242..bc22e1e13 100644 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator.ts +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator.ts @@ -1,19 +1,765 @@ 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 { getInputTokenBudget } from "@App/app/service/agent/core/model_context"; -import { ToolLoopOrchestrator as BaseToolLoopOrchestrator, type ToolLoopDeps } from "./tool_loop_orchestrator_base"; +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"; -export type { ToolLoopDeps } from "./tool_loop_orchestrator_base"; +// 上下文占用达到这些比例时,分批裁剪保留窗口外的旧 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; +} -/** 在原编排器外层统一预留输出 token 预算。 */ export class ToolLoopOrchestrator { + // 注意:不在构造器持有 toolRegistry。 + // 每次 callLLMWithToolLoop 由调用方传入(通常是 SessionToolRegistry), + // 保证并发会话各自使用独立的工具注册表,避免闭包互相覆盖。 constructor( private deps: ToolLoopDeps, private chatRepo: AgentChatRepo ) {} - async callLLMWithToolLoop(params: Parameters[0]) { - const inputBudget = getInputTokenBudget(params.model); - const orchestrator = new BaseToolLoopOrchestrator(this.deps, this.chatRepo); - return orchestrator.callLLMWithToolLoop({ ...params, inputTokenBudget: inputBudget }); + /** 上下文即使裁剪到底也无法容纳下一次请求时,落库 + 通知 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; + // 输入 token 预算;未传入时按模型推导(getInputTokenBudget:窗口 - 输出预留 - 安全边际) + 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, + 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 = params.inputTokenBudget ?? getInputTokenBudget(model); + const preflightBudgetWindow = Math.max(1, budgetWindow / 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/agent/service_worker/tool_loop_orchestrator_base.ts b/src/app/service/agent/service_worker/tool_loop_orchestrator_base.ts deleted file mode 100644 index 053828ee1..000000000 --- a/src/app/service/agent/service_worker/tool_loop_orchestrator_base.ts +++ /dev/null @@ -1,766 +0,0 @@ -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, - }); - } - } -} From 5fcb92d9d83758bcffa31e8987d22f0b2c646a15 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 16 Jul 2026 07:35:20 +0900 Subject: [PATCH 35/38] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=E5=90=88=E5=B9=B6?= =?UTF-8?q?=20ChatService=20=E5=8C=85=E8=A3=85=E5=AD=90=E7=B1=BB=E4=B8=8E?= =?UTF-8?q?=E5=9F=BA=E7=B1=BB=E4=B8=BA=E5=8D=95=E4=B8=80=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chat_service.ts 原本以 params: any 的子类 + Proxy 包装 sender 的方式, 在基类之外叠加 executeTools 的 requestId 批次关联;合并为单文件单类: 连接包装收敛为 wrapScriptToolConnection(),handleConversationChat 入口 处包装一次并显式向下传递 MessageConnect,去掉 Proxy 与 any 参数, 删除 chat_service_base.ts(模块路径与公开导出保持不变)。 Co-Authored-By: Claude Fable 5 --- src/app/repo/opfs_repo.ts | 2 +- .../agent/service_worker/background.test.ts | 2 +- .../agent/service_worker/chat_service.ts | 961 ++++++++++++++++-- .../agent/service_worker/chat_service_base.ts | 838 --------------- 4 files changed, 894 insertions(+), 909 deletions(-) delete mode 100644 src/app/service/agent/service_worker/chat_service_base.ts diff --git a/src/app/repo/opfs_repo.ts b/src/app/repo/opfs_repo.ts index 0104be3fd..4aaf2d0f8 100644 --- a/src/app/repo/opfs_repo.ts +++ b/src/app/repo/opfs_repo.ts @@ -62,7 +62,7 @@ export class OPFSRepo { // 传入 signal 时,若在"调用 close() 之前"已 abort,则改为 writable.abort() 放弃这次写入。 // 诚实说明这里的边界:这只保证 close() 调用前的 abort 一定不会提交;一旦 close() 已经 // 发出,FSA 规范不提供可靠的方式中途取消它,abort 恰好落在 close() 进行期间这个极窄窗口 - // 理论上仍可能提交。调用方(compact_service.ts / chat_service_base.ts)都会在 + // 理论上仍可能提交。调用方(compact_service.ts / chat_service.ts)都会在 // saveMessages() resolve 之后再次检查 signal,因此即使命中这个窗口,也不会对外报告 // 虚假的成功事件(compact_done/done)——唯一的残留风险是磁盘内容被替换但会话已判定为 // 取消,这是一个已知的、极窄的边界情况,未做完整的事务回滚(见 finding 2)。 diff --git a/src/app/service/agent/service_worker/background.test.ts b/src/app/service/agent/service_worker/background.test.ts index 9f1ef4ac5..ae260eed3 100644 --- a/src/app/service/agent/service_worker/background.test.ts +++ b/src/app/service/agent/service_worker/background.test.ts @@ -363,7 +363,7 @@ describe("handleAttachToConversation 重连逻辑", () => { (service as any).bgSessionManager.set("conv-late-error", rc); // 模拟 emitCancelled() 广播的终态事件已经先经过 sendEvent → updateStreamingState - // 把 status 从 cancelling 改写成 error(真实时序:chat_service_base.ts 的 sendEvent + // 把 status 从 cancelling 改写成 error(真实时序:chat_service.ts 的 sendEvent // 总是先 updateStreamingState 再 finalizeCancelled 才被调用) (service as any).bgSessionManager.updateStreamingState(rc, { type: "error", message: "x" }); expect(rc.status).toBe("error"); diff --git a/src/app/service/agent/service_worker/chat_service.ts b/src/app/service/agent/service_worker/chat_service.ts index 1d117bb09..92b8e8461 100644 --- a/src/app/service/agent/service_worker/chat_service.ts +++ b/src/app/service/agent/service_worker/chat_service.ts @@ -1,87 +1,910 @@ import type { IGetSender } from "@Packages/message/server"; +import { GetSenderType } from "@Packages/message/server"; import type { MessageConnect } from "@Packages/message/types"; -import { uuidv4 } from "@App/pkg/utils/uuid"; +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 { 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); +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"]; +} + +/** chat / compact / clearMessages / 定时任务续接共用的按 conversationId 队列锁 key。 + * 所有对同一会话持久化消息的读改写路径都必须经由这把锁串行,否则 appendMessage 的 + * 读-改-写会互相覆盖丢消息。 */ +export function conversationChatLockKey(conversationId: string): string { + return `agent-chat:${conversationId}`; +} + +/** 为脚本工具连接增加 executeTools 请求批次关联(requestId),并在后台客户端离线后 + * 直接返回结构化错误结果:过期批次的 toolResults 被丢弃,不会被下一个批次误认领; + * 连接已断开时不再把 executeTools 扔进黑洞,而是立刻回填整批 error 结果,让 tool loop + * 能继续走统一的失败路径而不是挂起等待。 */ +function wrapScriptToolConnection(original: MessageConnect): MessageConnect { + 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 { + 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; + } + callback(message); }); - }; + }, + sendMessage(message: any) { + if (message.action !== "executeTools") { + if (!disconnected) original.sendMessage(message); + return; + } - 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; - } - callback(message); + const correlated = { ...message, requestId: uuidv4() }; + activeRequestId = correlated.requestId; + if (disconnected) { + failBatch(correlated, "Script tool client is unavailable"); + return; + } + try { + original.sendMessage(correlated); + } catch (error) { + disconnected = true; + failBatch( + correlated, + error instanceof Error && error.message ? error.message : "Script tool client is unavailable" + ); + } + }, + disconnect(ignoreAlreadyDisconnected?: boolean) { + original.disconnect(ignoreAlreadyDisconnected); + }, + onDisconnect(callback) { + original.onDisconnect((isSelfDisconnected) => { + disconnected = true; + callback(isSelfDisconnected); + }); + }, + }; +} + +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(conversationChatLockKey(params.conversationId), async () => { + await this.chatRepo.saveMessages(params.conversationId, []); + return true; }); - }, - sendMessage(message: any) { - if (message.action !== "executeTools") { - if (!disconnected) original.sendMessage(message); - return; + 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 = wrapScriptToolConnection(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, msgConn); + } + + return stackAsyncTask(conversationChatLockKey(params.conversationId), () => + this.handleConversationChatLocked(params, msgConn) + ); + } + + private async handleConversationChatLocked(params: ConversationChatParams, msgConn: MessageConnect) { + // 后台模式:非 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 }); } + } + }; - const correlated = { ...message, requestId: uuidv4() }; - activeRequestId = correlated.requestId; - if (disconnected) { - failBatch(correlated, "Script tool client is unavailable"); + // 循环检测(tool_call_guard)连续命中时暂停询问用户是否继续;复用 ask_user 的事件/resolver 机制, + // 5 分钟无人应答时默认"继续",避免无 UI 监听的后台会话被无限期挂起 + const askUserForGuard = (strikeCount: number): Promise => { + return new Promise((resolve) => { + if (abortController.signal.aborted) { + resolve("stop"); return; } - try { - original.sendMessage(correlated); - } catch (error) { - disconnected = true; - failBatch( - correlated, - error instanceof Error && error.message ? error.message : "Script tool client is unavailable" + 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")); } - }, - disconnect(ignoreAlreadyDisconnected?: boolean) { - original.disconnect(ignoreAlreadyDisconnected); - }, - onDisconnect(callback) { - original.onDisconnect((isSelfDisconnected) => { - disconnected = true; - callback(isSelfDisconnected); + }); + }; + + 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; - const wrappedSender = new Proxy(sender, { - get(target, property, receiver) { - if (property === "getConnect") return () => connection; - return Reflect.get(target, property, receiver); - }, + 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, }); - return super.handleConversationChat(params, wrappedSender); + 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/chat_service_base.ts b/src/app/service/agent/service_worker/chat_service_base.ts deleted file mode 100644 index e9c0c5529..000000000 --- a/src/app/service/agent/service_worker/chat_service_base.ts +++ /dev/null @@ -1,838 +0,0 @@ -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 }; - } -} From eff43a0dbf984831e90f177a786ab2a7607a331d Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 16 Jul 2026 07:35:25 +0900 Subject: [PATCH 36/38] =?UTF-8?q?=F0=9F=90=9B=20=E5=AE=9A=E6=97=B6?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E7=BB=AD=E6=8E=A5=E4=BC=9A=E8=AF=9D=E6=97=B6?= =?UTF-8?q?=E7=BA=B3=E5=85=A5=20agent-chat=20=E4=BC=9A=E8=AF=9D=E9=98=9F?= =?UTF-8?q?=E5=88=97=E9=94=81=EF=BC=8C=E9=98=B2=E6=AD=A2=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E5=86=99=E8=A6=86=E7=9B=96=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本 PR 为 chat/compact/clearMessages 引入了按 conversationId 的队列锁, 但 executeInternalTask 未走这把锁:appendMessage 是读-改-写整份 OPFS JSON,续接已有会话的定时任务与进行中的 UI 对话并发时会互相覆盖丢消息。 导出共享的 conversationChatLockKey,定时任务执行体排入同一队列。 Co-Authored-By: Claude Fable 5 --- .../agent/service_worker/task_service.test.ts | 82 +++++++++++++++++++ .../agent/service_worker/task_service.ts | 21 ++++- 2 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 src/app/service/agent/service_worker/task_service.test.ts diff --git a/src/app/service/agent/service_worker/task_service.test.ts b/src/app/service/agent/service_worker/task_service.test.ts new file mode 100644 index 000000000..99310fb23 --- /dev/null +++ b/src/app/service/agent/service_worker/task_service.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from "vitest"; +import { AgentTaskService } from "./task_service"; +import { conversationChatLockKey } from "./chat_service"; +import { stackAsyncTask } from "@App/pkg/utils/async_queue"; +import type { InternalAgentTask } from "@App/app/service/agent/core/types"; + +function createService(overrides?: { appendMessage?: ReturnType }) { + const appendMessage = overrides?.appendMessage ?? vi.fn().mockResolvedValue(undefined); + const repo = { + appendMessage, + getMessages: vi.fn().mockResolvedValue([]), + listConversations: vi.fn().mockResolvedValue([{ id: "conv-lock", title: "t", modelId: "m1" }]), + saveConversation: vi.fn().mockResolvedValue(undefined), + } as any; + const orchestrator = { + getModel: vi.fn().mockResolvedValue({ id: "m1", model: "gpt-4o", provider: "openai" }), + callLLMWithToolLoop: vi.fn().mockResolvedValue(undefined), + }; + const skillService = { resolveSkills: vi.fn().mockReturnValue({ promptSuffix: "", metaTools: [] }) } as any; + const service = new AgentTaskService( + {} as any, + repo, + {} as any, + skillService, + orchestrator as any, + {} as any, + {} as any + ); + return { service, repo, orchestrator }; +} + +describe("AgentTaskService 定时任务与会话锁", () => { + it("续接已有会话的定时任务必须等待 agent-chat 会话锁释放后才写入消息", async () => { + const { service, repo } = createService(); + + // 模拟一条正在进行的 UI 对话占用同一会话的队列锁 + let releaseLock!: () => void; + const lockHeld = new Promise((resolve) => { + releaseLock = resolve; + }); + const lockTask = stackAsyncTask(conversationChatLockKey("conv-lock"), () => lockHeld); + + const task = { + id: "task-1", + name: "定时任务", + mode: "internal", + prompt: "继续", + conversationId: "conv-lock", + } as unknown as InternalAgentTask; + + const runPromise = service.executeInternalTask(task); + + // 锁被 UI 对话占用期间,定时任务不得写入任何消息(appendMessage 是读改写,会互相覆盖) + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(repo.appendMessage).not.toHaveBeenCalled(); + + releaseLock(); + await lockTask; + const result = await runPromise; + + expect(result.conversationId).toBe("conv-lock"); + expect(repo.appendMessage).toHaveBeenCalledTimes(1); + }); + + it("新建会话的定时任务正常执行并返回新 conversationId", async () => { + const { service, repo, orchestrator } = createService(); + const task = { + id: "task-2", + name: "新任务", + mode: "internal", + prompt: "hello", + } as unknown as InternalAgentTask; + + const result = await service.executeInternalTask(task); + + expect(result.conversationId).toBeTruthy(); + expect(repo.saveConversation).toHaveBeenCalled(); + expect(orchestrator.callLLMWithToolLoop).toHaveBeenCalledWith( + expect.objectContaining({ conversationId: result.conversationId, rehydratedHistory: false }) + ); + }); +}); diff --git a/src/app/service/agent/service_worker/task_service.ts b/src/app/service/agent/service_worker/task_service.ts index 362e07ce2..ccdec66c3 100644 --- a/src/app/service/agent/service_worker/task_service.ts +++ b/src/app/service/agent/service_worker/task_service.ts @@ -23,6 +23,8 @@ 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"; +import { stackAsyncTask } from "@App/pkg/utils/async_queue"; +import { conversationChatLockKey } from "./chat_service"; /** 供 TaskService 调用的 orchestrator 能力 */ export interface TaskOrchestrator { @@ -75,13 +77,27 @@ export class AgentTaskService { sessionRegistry.register("skill", mt.definition, mt.executor); } + // 与同一会话的 UI 聊天 / compact / clearMessages 共用同一把按 conversationId 的队列锁: + // appendMessage 是读-改-写,续接已有会话的定时任务若不排队,会与进行中的对话互相覆盖丢消息 + const conversationId = task.conversationId || uuidv4(); + return stackAsyncTask(conversationChatLockKey(conversationId), () => + this.executeInternalTaskLocked(task, conversationId, model, promptSuffix, metaTools, sessionRegistry) + ); + } + + private async executeInternalTaskLocked( + task: InternalAgentTask, + conversationId: string, + model: AgentModelConfig, + promptSuffix: string, + metaTools: ReturnType["metaTools"], + sessionRegistry: SessionToolRegistry + ): Promise<{ conversationId: string; usage?: { inputTokens: number; outputTokens: number } }> { try { - let conversationId: string; const messages: ChatRequest["messages"] = []; if (task.conversationId) { // 续接已有对话 - conversationId = task.conversationId; const conv = await this.getConversation(conversationId); const systemContent = buildSystemPrompt({ @@ -124,7 +140,6 @@ export class AgentTaskService { } } else { // 创建新对话 - conversationId = uuidv4(); const conv: Conversation = { id: conversationId, title: task.name, From 47ec4214bf7cd165334d70cba8cca8a0bd681914 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 16 Jul 2026 07:42:15 +0900 Subject: [PATCH 37/38] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=E5=90=88=E5=B9=B6?= =?UTF-8?q?=20cat=5Fagent=20=E5=8E=9F=E5=9E=8B=E8=A1=A5=E4=B8=81=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E5=B1=82=E4=B8=8E=E5=9F=BA=E7=B1=BB=E4=B8=BA=E5=8D=95?= =?UTF-8?q?=E4=B8=80=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cat_agent.ts 原本通过直接改写 cat_agent_base.ts 的 prototype 替换 mergeTools / executeTools / processChat / processStream / processStreamEphemeral 五个方法,基类中这五个方法的实现是永远不会 执行的死代码,且已与真实实现漂移(基类 mergeTools 不替换同名 schema、 processStream 缺 requestId 关联 / 终态断开 / return()/throw() 处理等)。 合并为单文件单类,方法签名如实反映运行时行为(chatStream 产出 StreamChunk,attach 产出含 sync 快照的 ConversationStreamChunk), 删除 cat_agent_base.ts;cat_agent.test.ts 全部 83 个用例无改动通过, 证明合并前后行为一致。 Co-Authored-By: Claude Fable 5 --- src/app/service/content/gm_api/cat_agent.ts | 1111 +++++++++++------ .../service/content/gm_api/cat_agent_base.ts | 730 ----------- 2 files changed, 727 insertions(+), 1114 deletions(-) delete mode 100644 src/app/service/content/gm_api/cat_agent_base.ts diff --git a/src/app/service/content/gm_api/cat_agent.ts b/src/app/service/content/gm_api/cat_agent.ts index 409677309..f57b9a002 100644 --- a/src/app/service/content/gm_api/cat_agent.ts +++ b/src/app/service/content/gm_api/cat_agent.ts @@ -1,17 +1,26 @@ -import BaseApi, { ConversationInstance as BaseConversationInstance } from "./cat_agent_base"; +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, - MessageContent, - StreamChunk as BaseStreamChunk, + 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 type ConversationStreamChunk = - | BaseStreamChunk + | StreamChunk | { type: "sync"; streamingMessage?: { @@ -37,7 +46,6 @@ export type ConversationStreamChunk = }; type ToolHandler = (args: Record) => Promise; -type Instance = BaseConversationInstance & Record; function cloneToolCall(toolCall: ToolCall): ToolCall { return { @@ -71,117 +79,446 @@ function resolveToolCall( return undefined; } -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; +// 对话实例,暴露给用户脚本 +// 导出供测试使用 +export class ConversationInstance { + public toolHandlers: Map = 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 }); + } } - 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; + + // 注册内置 /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); + } } - 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, - }); + } + + 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; } - return results; -} -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, + // 流式发送消息 + 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, }; - 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); + + 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]); + + // chat 连接不会收到 sync 事件(sync 快照仅由 attach 的 SW 端发出), + // 公开签名与 scriptcat.d.ts 保持一致:chatStream 只产出 StreamChunk + // ephemeral 模式:包装 stream 以收集 assistant 消息到内存历史 + if (this.ephemeral) { + return this.processStreamEphemeral(conn, handlers) as AsyncIterable; + } + + return this.processStream(conn, handlers) as AsyncIterable; } - return { toolDefs, handlers }; -} -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, + // 解析命令:"/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 }; + } + + // 合并实例级别和调用级别的工具定义(调用级同名工具同时替换 schema 与 handler) + protected mergeTools(callTools?: ChatOptions["tools"]) { + 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 }; + } + + // 获取对话历史 + 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, + ]); + } + + // 附加到后台运行中的会话,返回流式事件(首个 chunk 为 sync 快照) + 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 { + 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, + }); + } + } + } + content = ""; + blocks = []; + ordered = []; + byId = new Map(); + return finalContent; + }; + + conn.onMessage(async (message: any) => { + if (message.action === "executeTools") { + const data = await this.executeTools(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; } - } - content = ""; - blocks = []; - ordered = []; - byId = new Map(); - return finalContent; + if (message.action !== "event") return; + const event: ChatStreamEvent = message.data; + if ("subAgent" in event && event.subAgent) return; + switch (event.type) { + case "content_delta": + content += event.delta; + break; + case "thinking_delta": + thinking += event.delta; + break; + case "content_block_complete": + 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 "new_message": + finishRound(); + break; + case "done": + 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": + settled = true; + reject(Object.assign(new Error(event.message), event)); + conn.disconnect(); + break; + } + }); + conn.onDisconnect(() => { + if (settled) return; + settled = true; + reject(new Error("Connection disconnected")); + }); + }); + } + + // 处理流式 chat 的响应 + protected processStream( + 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, () => settled); - // 工具函数执行期间连接可能已经因 Stop/脚本工具超时而 settle 并断开; + const data = await this.executeTools(message.data, handlers, () => done); + // 工具函数执行期间连接可能已经因 Stop/脚本工具超时而结束并断开; // 断开后的连接 sendMessage 会抛错,且这里是异步回调,事件源不会 await/捕获它, // 会在用户脚本上下文里变成 unhandled rejection(见 finding 7) - if (settled) return; + if (done) return; try { conn.sendMessage({ action: "toolResults", @@ -197,14 +534,33 @@ function processChat(this: Instance, conn: MessageConnect, handlers: Map ({ ...task })), + status: event.status, + }); + done = event.status !== "running"; + // 终态快照:attach 的会话已经结束,SW 侧不会再为这条连接注册 listener, + // 也就不会再有后续事件——必须在这里主动断开,否则 port 会一直挂着 + if (done) conn.disconnect(); + break; case "content_delta": - content += event.delta; + push({ type: "content_delta", content: event.delta }); break; case "thinking_delta": - thinking += event.delta; + push({ type: "thinking_delta", content: event.delta }); break; case "content_block_complete": - blocks.push(event.block); + push({ type: "content_block", block: event.block }); break; case "tool_call_start": { const toolCall: ToolCall = { @@ -214,11 +570,15 @@ function processChat(this: Instance, conn: MessageConnect, handlers: Map { - if (settled) return; - settled = true; - reject(new Error("Connection disconnected")); + if (done) return; + done = true; + error = new Error("Connection disconnected"); + wake?.(); }); - }); -} - -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) + // 提前退出(for await...break、消费方 throw)时必须断开连接并唤醒挂起的 next(), + // 否则 port/listener 会一直挂在 SW 侧,直到脚本上下文销毁 + const closeEarly = () => { if (done) return; + done = true; try { - conn.sendMessage({ - action: "toolResults", - requestId: message.requestId, - data, - }); + conn.disconnect(); } catch { - // 连接已断开,结果无处可送,安全忽略 + // port 可能已断开 } - 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), - } - : 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", + wake?.(); + }; + + 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; + }, }; - ordered.push(toolCall); - byId.set(toolCall.id, toolCall); - push({ type: "tool_call", toolCall: cloneToolCall(toolCall) }); - break; - } - 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) }); + }, + }; + } + + // 处理 ephemeral 流式 chat 的响应(收集 assistant 消息到内存历史) + protected processStreamEphemeral( + conn: MessageConnect, + handlers: Map + ): AsyncIterable { + const inner = this.processStream(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, + }); } - 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), + 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; + }, + }; + }, + }; + } + + // 执行用户定义的 tool handlers。 + // isSettled:连接/请求批次是否已经 settle(Stop、脚本工具超时、连接断开)。串行执行期间在每个 + // handler 之前检查,而不是只在整批结束后检查一次——否则已经 settle 之后,剩余的 + // handler 仍会继续跑,其副作用可能和后续新批次的 handler 重叠(见 finding 9) + protected async executeTools( + toolCalls: ToolCall[], + handlers: Map, + 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, }); - break; + continue; } - case "new_message": - push({ type: "new_message" }); - reset(); - break; - case "done": - push({ - type: "done", - usage: event.usage, - durationMs: event.durationMs, + const handler = handlers.get(toolCall.name); + if (!handler) { + results.push({ + id: toolCall.id, + result: JSON.stringify({ error: `Tool "${toolCall.name}" not found` }), + error: true, }); - done = true; - conn.disconnect(); - break; - case "error": - push({ - type: "error", - error: event.message, - errorCode: event.errorCode, - usage: event.usage, - durationMs: event.durationMs, + continue; + } + try { + const args = toolCall.arguments ? JSON.parse(toolCall.arguments) : {}; + results.push({ + id: toolCall.id, + result: stringifyToolResult(await handler(args)), }); - 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 可能已断开 + } 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, + }); + } } - wake?.(); - }; + return results; + } +} - 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; - }, - }; - }, - }; +// 运行时 this 是 GM_Base 实例,定义其实际拥有的字段类型 +interface GMBaseContext { + sendMessage: (api: string, params: unknown[]) => Promise; + connect: (api: string, params: unknown[]) => Promise; + scriptRes?: { uuid: string }; } -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; - }, - }; - }, - }; +// 构建 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 + ); } -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 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); + })(); + } -export const ConversationInstance = BaseConversationInstance; -export default BaseApi; + // 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/content/gm_api/cat_agent_base.ts b/src/app/service/content/gm_api/cat_agent_base.ts deleted file mode 100644 index d85f68e3e..000000000 --- a/src/app/service/content/gm_api/cat_agent_base.ts +++ /dev/null @@ -1,730 +0,0 @@ -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); - })(); - } -} From f648cc42fc8e5f223dc4af4e66a83b2e61f5520f Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 16 Jul 2026 07:42:55 +0900 Subject: [PATCH 38/38] =?UTF-8?q?=F0=9F=8E=A8=20Settings=20=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=20agent=5Fconfig=20=E5=AF=BC=E5=87=BA=E7=9A=84?= =?UTF-8?q?=E6=9C=80=E5=A4=A7=E5=B7=A5=E5=85=B7=E8=B0=83=E7=94=A8=E6=AC=A1?= =?UTF-8?q?=E6=95=B0=E4=B8=8A=E4=B8=8B=E9=99=90=E5=B8=B8=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设置页原地重复声明 MIN/MAX_CHAT_MAX_ITERATIONS(1/1000),与 agent_config.ts 的同名导出各存一份,后续调整边界时容易漂移。 Co-Authored-By: Claude Fable 5 --- src/pages/options/routes/Agent/Settings/index.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/pages/options/routes/Agent/Settings/index.tsx b/src/pages/options/routes/Agent/Settings/index.tsx index 7292be904..f93e6cb50 100644 --- a/src/pages/options/routes/Agent/Settings/index.tsx +++ b/src/pages/options/routes/Agent/Settings/index.tsx @@ -14,11 +14,14 @@ 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"; +import { + DEFAULT_CHAT_MAX_ITERATIONS, + MIN_CHAT_MAX_ITERATIONS, + MAX_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"];