Skip to content

Agent 对话可配置最大工具调用次数 + Token 优化 + 循环检测升级#1549

Draft
cyfung1031 wants to merge 34 commits into
scriptscat:mainfrom
cyfung1031:feat/ai-001
Draft

Agent 对话可配置最大工具调用次数 + Token 优化 + 循环检测升级#1549
cyfung1031 wants to merge 34 commits into
scriptscat:mainfrom
cyfung1031:feat/ai-001

Conversation

@cyfung1031

@cyfung1031 cyfung1031 commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Checklist / 检查清单

背景

关联 #1545,解决内置 Agent 在长时间、高频 Tool Calling 场景中的三个问题:

  1. 最大迭代次数固定为 50,复杂任务容易被强制中断;
  2. 历史工具结果会在后续请求中重复携带,导致 Token 消耗持续增长;
  3. 检测到重复/循环调用后仅提示模型,无法让用户及时介入。

实现内容

1. 可配置最大工具调用次数,并支持原会话继续执行

  • 新增 AgentConfigRepo,通过 chrome.storage.local 持久化 chatMaxIterations
    • 默认值:50
    • 可配置范围:1–1000
    • 对设置页输入、直接传参和异常/损坏的存储值统一进行取整、范围截断和默认值回退
  • Agent Settings 新增“对话”分类及“最大工具调用次数”配置项。
  • 达到迭代上限时:
    • 持久化带 errorCode: "max_iterations" 的错误消息;
    • UI 显示“继续对话”按钮;
    • 点击后在原会话中追加继续指令,并复用已持久化的历史继续执行,无需新建会话。
  • 重建 LLM 上下文时跳过仅供 UI 展示的错误占位消息,避免将空的 assistant error message 重放给 Provider。

2. 降低长 Tool Loop 的 Token 消耗

Anthropic Prompt Cache

  • 在 Anthropic 请求的最后一条消息上追加 cache_control 断点,使下一轮能够复用已经生成的消息前缀。
  • 与已有的 system、tools 缓存断点配合,减少长 Tool Loop 中重复输入的计费 Token。

execute_script 大结果截断

  • 序列化结果超过 30000 字符时,仅保留首尾各 15000 字符。
  • 返回值附带 truncated: trueoriginal_length,并提示 Agent 缩小返回结果或将大数据写入 OPFS。

Tool Result 滑动窗口裁剪

  • 根据实际上下文占用,在 40%60% 阈值分批触发裁剪;达到 60% 后,每新增 5 轮工具调用再滑动一次窗口。
  • 保留最近 5 轮 assistant/tool 消息原文,更早的 tool result 替换为占位文本。
  • 仅修改当前请求中传给 LLM 的内存消息,不影响 OPFS 中的完整历史和 UI 展示。
  • 80% 以上仍优先使用现有 auto-compact 流程。
  • Anthropic 的上下文占用计算会合并普通输入、cache creation 和 cache read Token,避免缓存用量导致阈值判断失真。

3. Loop Guard 支持用户介入

  • 保留现有 tool_call_guard 的模型提醒和 system_warning 展示。
  • 自上次确认后累计触发 2 次警告时,暂停 UI 对话并询问用户:
    • Continue:继续执行,并重置警告计数;
    • Stop:持久化停止说明并以 done 正常结束,而不是报错。
  • 普通 UI 会话和后台会话均支持该交互;后台会话可在重新 attach 后恢复待回答的问题。
  • 5 分钟无人回答时默认继续,并同步清理过期的 pendingAskUser 状态。
  • 定时任务和子代理不传入交互回调,继续保持“只告警、不暂停”,避免无人值守任务被阻塞。
  • 等待用户回答期间支持通过 AbortSignal 立即停止。

兼容性

  • 默认最大迭代次数仍为 50,未修改设置的用户行为保持不变。
  • 上下文裁剪不改写持久化消息,因此刷新页面、继续对话和历史展示不会丢失原始工具结果。
  • 新增文案已覆盖全部 8 个 locale:zh-CN、en-US、zh-TW、ja-JP、ru-RU、de-DE、vi-VN、tr-TR。

Test plan

  • 按 TDD 流程为新增行为和审查修复补充回归测试
  • npm run test:ci — 3041/3041 tests passed(289 个测试文件)
  • npm run lint — prettier / tsc / eslint 全部通过
  • 覆盖配置边界值、继续对话、Anthropic cache、结果截断、上下文裁剪、Loop Guard 暂停/继续/停止/超时/中止等场景

调查过程与技术方案细节见 issue-1545-proposal.md

cyfung1031 and others added 6 commits July 7, 2026 02:24
针对 scriptscat#1545:UI 对话此前硬编码 50 次工具调用上限且无法调整,达到上限
后用户误以为必须新建对话、丢失已探索的上下文。

- 新增 AgentConfigRepo(chatMaxIterations,默认 50,1-1000),Settings
  页新增"对话"分类可视化配置
- 达到 max_iterations 时持久化 errorCode,聊天界面据此渲染"继续对话"
  按钮,一键复用已持久化的完整历史继续执行
- 全部 8 个 locale 补齐相应文案

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
针对 scriptscat#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 <noreply@anthropic.com>
针对 scriptscat#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 <noreply@anthropic.com>
针对 scriptscat#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 <noreply@anthropic.com>
针对 PR scriptscat#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 <noreply@anthropic.com>
@cyfung1031

This comment was marked as outdated.

Copy link
Copy Markdown
Collaborator Author

已完成并推送到 feat/ai-001(commit cc1261e9)。本次修复包括:

  • 续接持久化会话时,在首个 LLM 请求前对独立历史副本执行 tool 结果裁剪,保留 OPFS/UI 完整历史。
  • loop-guard 超时发送 ask_user_expired,清理前台 pending 状态并继续执行。
  • 统一 chat、compact、定时任务的持久化消息重建,过滤错误占位消息。
  • 仅允许最后一个 max_iterations 错误助手组显示继续按钮。
  • 统一 ephemeral、UI、定时任务的 maxIterations 归一化到 1–1000。
  • execute_script 按最终 JSON 信封大小限制截断,并覆盖引号/反斜杠回归场景。

验证:相关 5 个测试文件 95 个测试通过;pnpm run lint(Prettier、TypeScript、ESLint)通过。

Copy link
Copy Markdown
Collaborator Author

已根据完整 pnpm test 结果补充并推送修复,最新提交为 bb142ca6(前一提交为 cc1261e9)。

本次补充修复了持久化历史裁剪与既有内存消息循环语义之间的边界:只有正常续接持久化会话和续接已有会话的定时任务启用首个 LLM 请求前的历史副本裁剪;ephemeral/direct tool-loop 调用继续保留原有消息数组行为。

最终验证:

  • pnpm test:289 个测试文件、3044 个测试全部通过
  • pnpm run lint:Prettier、TypeScript、ESLint 全部通过
  • 已推送至 feat/ai-001

Copy link
Copy Markdown
Collaborator Author

已完成本轮问题修复并推送到 feat/ai-001(提交 ad31fd2d)。

  • 定时任务遇到 max_iterations 时现在标记为失败,并保留累计 usage。
  • 续接历史仅在估算上下文达到 40% 阈值时预裁剪;手动 /compact 先过滤错误占位消息,再裁剪旧工具结果。
  • 循环检测改用稳定的 continue/stop action,禁止自定义输入;补充过期、取消及多监听器的状态广播。
  • max_iterations 终态保存并发送 usage/duration;实时错误透传 errorCode
  • execute_script 现在按最终 envelope 限制 30,000 字符,并覆盖边界与转义内容测试。
  • 循环检测问题、选项和停止消息已补齐八种语言。

验证结果:

  • pnpm run typecheck 通过
  • pnpm run lint 通过
  • 目标相关测试 92 项通过
  • pnpm test 在并行运行时有 1 个既有 ScriptEditor 预加载测试时序失败(3048/3049 通过);该测试单独复跑 3/3 通过

Copy link
Copy Markdown
Collaborator Author

已继续修复并推送到 feat/ai-001(提交 769c15bf)。

  • userscript API 调用不再注入无法响应的 loop-guard 交互回调,避免无 UI 客户端暂停五分钟。
  • attached background responder 现在通过 broadcastEvent 向所有监听器发送 ask_user_resolved
  • 首次续接与手动 /compact 使用包含工具定义的 UTF-8 保守请求预算;仅在超过安全阈值时逐步裁剪工具结果,兼顾小历史和小上下文模型。
  • 定时任务失败保留 conversationId;userscript chat/chatStream、sub-agent 与 UI 均透传终态 error 的 usage/duration。
  • loop-guard 等待在 abort 与微任务竞态下不会创建残留 prompt、resolver 或 timer。

验证结果:

  • 目标回归测试 89 项通过
  • pnpm run typecheck 通过
  • pnpm run lint 通过
  • pnpm test:3051/3052 通过;唯一失败项为既有 lint harness 测试并行运行时的 340ms 超时,单独复跑 23/23 通过

Copy link
Copy Markdown
Collaborator Author

已完成本轮修复并推送到 feat/ai-001(提交 256705d8)。

  • userscript 会话不再注册无法由公开 API 响应的 ask_user 工具。
  • 普通 ask_user 接入 AbortSignal;用户回答、超时和中止都会清理 resolver/timer,并广播 ask_user_resolvedask_user_expired
  • 上下文预算估算改用 UTF-8 字节保守上界,支持报告裁剪是否达标;无法安全裁剪的续接/compact 请求会返回明确的上下文过大错误,并识别未知多模态附件成本。
  • provider 直接抛错现在携带累计 usage、duration 和 conversationId;定时任务与子代理使用结构化失败终态。
  • 子代理达到 max_iterations 时工具调用标记为失败,不再返回普通成功的空结果。
  • 公开 chat()/chatStream() 统一透传 error 的 usage 与 durationMs。

验证结果:

  • pnpm test:289 个测试文件、3054 个测试全部通过
  • pnpm run typecheck 通过
  • pnpm run lint 通过

Copy link
Copy Markdown
Collaborator Author

已完成本轮修复并推送到 feat/ai-001(提交 cbb6d8fc)。

  • 多模态上下文预算现在读取附件 Blob 实际大小,估算 provider 展开后的 data URL 成本;旧附件可替换为可恢复占位,当前附件按真实大小参与 overflow 判断,初始与续接请求采用一致的安全决策。
  • 自动 compact 纳入编排器结构化终态边界,摘要请求也执行上下文预算检查;失败时保留累计 usage、duration 和 conversationId。
  • context overflow 在发送事件前先持久化终态错误,定时任务不再依赖 error listener 抛异常控制流程。
  • 工具失败状态贯穿排除工具、未知工具、userscript handler、注册工具、tool_call_complete、后台快照、前台 UI 和持久化状态。
  • 子代理终态异常保留部分消息、usage 与 subAgentDetails,刷新后仍可查看失败轨迹。
  • ask_user_resolved 统一由 resolver 层发送,普通回答不再重复广播;guard 与普通 ask_user 使用同一终态契约。
  • 公开 API 的成功 chat()/chatStream() 也透传 durationMs

验证结果:

  • pnpm test:289 个测试文件、3058 个测试全部通过
  • pnpm run typecheck 通过
  • pnpm run lint 通过

@cyfung1031 cyfung1031 marked this pull request as draft July 12, 2026 00:45
cyfung1031 and others added 5 commits July 12, 2026 10:17
每轮 LLM 调用前用当前 messages + 工具定义重新估算体积,超出安全阈值时立即裁剪;
不再仅依赖上一轮响应的 usage 反馈,避免巨大 tool 结果在下一次请求发出前就把上下文撑爆。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
file/audio 从不被 provider 内联,image 只在 vision 模型上才会解析为 data URL;
按类型和模型能力估算体积,避免非 vision 场景或无法读取的附件把预算估算撑到 Infinity。
裁剪后的占位文本保留 type/attachmentId(OPFS 路径),而非完全丢弃可恢复信息。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
非流式与流式适配器都只处理了 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 <noreply@anthropic.com>
orchestrator 的 callLLM/autoCompact 原生失败只 throw、不 sendEvent,导致实时 UI 收不到
子代理的终态事件、卡在 running 直到刷新;在捕获异常时补发一次 error 事件(若尚未上报过
终态)。同时 tool_call_complete 记录状态改为 event.status ?? "completed",避免持久化后
把失败的嵌套工具显示为成功。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
后台会话 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 <noreply@anthropic.com>
@cyfung1031

Copy link
Copy Markdown
Collaborator Author

本次推送修复了 code review 中发现的 7 个问题(均已本地验证:pnpm run test 3069 个测试通过,pnpm run typecheck 无报错):

  1. 请求前预算检查:每轮 LLM 调用前用当前 messages + 工具定义重新估算体积,超出安全阈值时立即裁剪;不再仅依赖上一轮响应的 usage 反馈,避免巨大 tool 结果在下一次请求发出前就把上下文撑爆(tool_loop_orchestrator.ts)。
  2. 附件预算按 provider 实际内联行为估算file/audio 从不被 provider 内联,image 只在 vision 模型上才会解析为 data URL;按类型和模型能力估算体积,避免非 vision 场景或无法读取的附件把预算估算撑到 Infinity;裁剪后的占位文本保留 type/attachmentId(OPFS 路径)而非完全丢弃(context_elision.ts)。
  3. 用户脚本工具循环遗漏 tool_call_complete/new_message 事件:非流式与流式适配器都只处理了 tool_call_start/delta,导致 ephemeral 历史中的 toolCalls 没有结果/终态,多轮对话被压平成一条消息、最终回复重复追加;已按轮次边界重建 assistant + 对应 tool 消息,并在 StreamChunk 新增 tool_call_complete/new_message 类型(cat_agent.ts)。
  4. 脚本工具回调 abort/断开/超时处理:脚本工具调用的 pending promise 现在会在 abort、连接断开或 5 分钟超时时主动结束,不再无限期挂起整条对话(chat_service.ts)。
  5. 子代理原生异常补发终态事件:orchestrator 的 callLLM/autoCompact 原生失败只 throw、不 sendEvent,导致实时 UI 收不到子代理终态、卡在 running 直到刷新;现在会在捕获异常时补发一次 error 事件(若尚未上报过终态)(sub_agent_service.ts)。
  6. tool_call_complete 状态不再硬编码:改为 event.status ?? "completed",避免持久化后把失败的嵌套工具显示为成功(sub_agent_service.ts)。
  7. 消除 ask_user 终态事件重复广播:后台会话 attach 不再重复广播 ask_user_resolved;loop-guard 超时路径不再在发 ask_user_expired 后又紧接着触发 ask_user_resolvedbackground_session_manager.ts / chat_service.ts)。

提交记录:b335f5c4 b17e1395 8c72058a 7df95858 fc587b26(均带回归测试,先复现问题再修复)。

🤖 Generated with Claude Code

Copy link
Copy Markdown
Collaborator Author

已按本轮审查 Findings 完成修复并推送到 feat/ai-001,当前 HEAD:ca0a46e413d759136a5559ec98b25d723024913e

主要修复:

  • attach() 公开并消费后台 sync 快照;终态快照立即结束,恢复已有内容、thinking、tool calls、ask_user 与 tasks。
  • userscript 非流式/流式适配器统一按 id -> index -> running fallback 路由并发 tool-call 参数;ChatReply.toolCalls 跨轮聚合;ephemeral 历史保留生成内容块;意外断连在排空队列后抛出错误。
  • 调用级同名工具同时替换 schema 与 handler;脚本工具 undefined 统一序列化为 "null"
  • script tool 执行批次增加 correlation ID,拒绝迟到响应;严格校验结果 ID 完整覆盖、无重复/越界;后台原连接断开后当前及后续脚本工具返回结构化错误,保证编排和持久化正常收尾。
  • 上下文 preflight 统一预留 provider 输出 token 与安全边际,normal/compact 路径使用同一输入预算;context_too_large 终态消息补存累计 usage。

验证:相关 TypeScript 文件已通过 Prettier 检查。当前受限执行环境无法解析完整项目的 Vitest 依赖,因此未宣称本地完整测试通过,后续以 PR CI 结果为准。

Copy link
Copy Markdown
Collaborator Author

Implemented on feat/ai-001:

  • Ignored forwarded subAgent lifecycle events in the public chat adapter so child done/error events no longer settle the parent conversation.
  • Reserved output tokens for explicit compact and now return context_too_large on overflow.
  • Aligned the canonical CAT.agent.conversation declarations with runtime behavior, including background, attach(), sync, durationMs, and cache usage fields.
  • Sanitized tool_call_complete handling so returned tool calls only carry tool-call fields and default status to completed.

Verified with pnpm exec eslint ... and pnpm run typecheck.

Copy link
Copy Markdown
Collaborator Author

Pushed feat/ai-001 with the requested fixes and local commit 41ff1155.

Summary:

  • fixed abort propagation for registered tools, skill scripts, and execute_script
  • fixed compact / summary budget handling so output reservation is applied once
  • updated canonical public declarations for durationMs, background, attach(), and sync
  • added regressions for missing-image estimation, summary preflight, and signal forwarding

Verified with pnpm exec tsc -p tsconfig.json --noEmit, pnpm exec eslint on the touched TypeScript files, and the targeted vitest set.

@cyfung1031

Copy link
Copy Markdown
Collaborator Author

已完成本次修复并提交 bd6c0d51

  • Stop 会话现在会广播取消终态、清理 pending 状态并安排注册表清理,允许同一 conversation ID 重新运行。
  • ToolRegistry 现在竞速 AbortSignal,规范化 undefined/不可序列化工具结果;OPFS/task 工具协作取消。
  • sandbox execute_script 超时会中止底层脚本;预检预算不再重复扣安全裕量;自动压缩 usage(含缓存 token)会计入总量。
  • 更新 signal 传递相关单元测试断言。

验证:pnpm run lint 通过;pnpm test 通过(290 个文件、3089 个测试)。

- 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 <noreply@anthropic.com>
@cyfung1031

Copy link
Copy Markdown
Collaborator Author

已在 feat/ai-001ae540d44)针对本轮 review 逐条修复:

  1. 后台会话取消竞态(High)BackgroundSessionManager 新增 cancelling 占位态——stop() 不再立即把记录标为终态,has()cancelling 期间仍视为占用,避免旧执行的 promise 尚未落定时同一 conversationId 被新会话顶替;执行方在 abort 真正落定后调用新增的 finalizeCancelled() 才收敛为终态(按 rc 实例做 CAS 校验,防止顶替上位的新会话被误终态化)。stop() 新增 expectedRc 参数,handleAttachchat_service_base 的 Stop 消息处理都改为按会话实例校验,避免旧连接的延迟 Stop 误杀新会话。见 background_session_manager.tschat_service_base.ts

  2. compact 取消竞态(High):自动 compact(compact_service.ts)与手动 compact(chat_service_base.tshandleCompactChat)都在 LLM 调用返回后、覆盖 currentMessages/持久化摘要前,以及持久化完成后再各补一次 abort 检查;tool_loop_orchestrator_base.ts 的主循环在 autoCompact() 返回后同样补了检查,取消后不会再继续持久化/广播 compact_donedone

  3. 取消终态化不完整(Medium)tool_loop_orchestrator_base.ts 新增统一的 emitCancelled() 收口路径——工具执行期间取消时,先把所有工具调用(含未及返回结果的)回写为 error/cancelled 状态并持久化,再发送唯一一条携带累计 usage/durationMs 的终态事件;LLM 响应后的 usage 改为先累加再检查 abort,避免最后一次响应的用量丢失。子代理的 usage 收集依赖 done/error 事件,因此这个修复同时解决了超时静默返回导致 usage 缺失的问题。

  4. 端口/监听器泄漏(Medium)options 页面的 useStreamingChat hook 在终态事件/终态 sync 后主动调用 conn.disconnect()cat_agent.tsprocessChatresolve/reject 前断开连接,processStream 的异步迭代器补上了 return()/throw()for await...break 或消费方抛错时会断开连接并唤醒挂起的 next()

  5. execute_script(target=page) 无法真正中止(Medium)chrome.scripting.executeScript 本身没有取消 API,无法安全地强制终止 MAIN world 代码。改为诚实呈现边界:超时/取消错误信息与工具描述都明确说明这只是"调用方停止等待",页面代码可能仍在 tab 内继续运行;target='sandbox' 路径不受影响,仍是真正可取消的。

  6. context_too_large 持久化 usage 不完整(Medium)emitContextTooLarge() 直接持久化 totalUsage(此时已包含 autoCompact 的用量),移除了外层 tool_loop_orchestrator.ts 中只包裹 callLLM、覆盖不到 autoCompact 的旧 usage 补丁 proxy。

  7. 字节数当 token 数(Low)estimateRequestTokens() 不再把 UTF-8 字节数直接当 token 数返回,改为除以一个保守换算常数(2 字节/token)后再与预算比较,仍然保守但不会把普通文本按 1 字节 = 1 token 过度低估上下文余量。

全部改动已跑过 tsc --noEmiteslintprettier --check 及相关单测(background.test.tscompact_service.test.tstool_loop_orchestrator.test.tsautocompact.test.tscontext_elision.test.tsexecute_script.test.tscat_agent.test.tshooks.test.ts 等),并补充/更新了对应用例以反映修正后的行为(尤其是 stop() 语义变化的测试)。

estimateRequestTokens 引入字节→token 保守折算后(见上一提交),命中目标
预算区间所需的字符数随之变大;原用例逐字符线性扫描长度、每次都做一次
O(len) 的 JSON.stringify,字符数变大后总耗时成倍增长,在 CI 上超过
340ms 的 fast 用例超时导致失败。

改为二分查找最小满足长度,迭代次数从 O(n) 降到 O(log n),用例总耗时从
超时降回个位数毫秒。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@cyfung1031

Copy link
Copy Markdown
Collaborator Author

CI 失败已修复(feat/ai-001 2f7467f1)。

根因:上一提交把 `estimateRequestTokens()` 从"UTF-8 字节数直接当 token 数"改成了"字节数按保守常数折算成 token 数",返回值整体变小。compact_service.test.ts摘要请求超过输出保留预算时应返回 context_too_large 这条用例用来构造测试数据的 makeCurrentMessages(),是从长度 1 开始逐字符线性扫描、每次都做一次 O(len) 的 JSON.stringify 来找命中目标预算区间的字符串长度——估算值变小后,命中区间所需的字符数随之变大,扫描总耗时成倍增长,超过了 vitest.config.ts 里 fast 分组 340ms 的用例超时,在 CI 上表现为 timeout(本地默认并发调度下偶发未复现)。

修复compact_service.test.ts 把逐字符线性扫描改成二分查找最小满足长度,迭代次数从 O(n) 降到 O(log n),测试逻辑不变,只是构造样本的方式更快。

验证:

  • npx vitest run 全量 3090 个用例通过(Duration 24.56s,该用例单独跑总耗时 6ms)
  • tsc --noEmiteslintprettier --check 均通过

上一提交在 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 <noreply@anthropic.com>
@cyfung1031

Copy link
Copy Markdown
Collaborator Author

e2e 失败已修复(`feat/ai-001` `c4b7dfa0`)。

根因:修复端口泄漏(finding 4)时,在 cat_agent.ts 的 `processChat` 里把顺序写成了先 `conn.disconnect()` 再 `resolve()/reject()`。部分连接实现的 `disconnect()` 会同步触发已注册的 `onDisconnect` 回调,而该回调里正是 `reject(new Error("Connection disconnected"))`——Promise 只认第一次 settle,于是这次 reject 抢在预期的 resolve 之前把整个 promise 判定为失败。结果是 `CAT.agent.conversation` 的每一次 `chat()` 调用最终都以 "Connection disconnected" 报错,对应 CI 里 `agent-conversation.spec.ts` 的三个用例(basic chat / tool calling / multi-turn)和 `agent-error-handling.spec.ts` 的重试用例全部失败;第二次 retry 里出现的 "waiting for event serviceworker" 超时是同一批用例反复失败导致的连锁重试超时,不是独立问题。

修复:把 `processChat` 的 done/error 分支改回先 `resolve()/reject()` 后 `conn.disconnect()`,并加了 `settled` 标记,让 `onDisconnect` 回调在 promise 已经终态化之后变成 no-op——不再依赖 "disconnect() 是否会同步触发回调" 这个具体实现细节。`processStream` 那边原本就是先 `done = true` 再 `conn.disconnect()`,且 `onDisconnect` 里已有 `if (done) return` 守卫,本身不受影响。

本地复现与验证

  • 未修复前本地复现出同样的 `ERROR: Connection disconnected`(`npx playwright test e2e/agent-conversation.spec.ts -g "basic chat"`)
  • 修复后:`npx playwright test e2e/agent-conversation.spec.ts`(3 项全过)、`npx playwright test e2e/agent-error-handling.spec.ts`(通过)、`npx playwright test e2e/agent-chat.spec.ts e2e/agent-navigation.spec.ts e2e/agent-provider.spec.ts`(9 项全过)
  • `npx vitest run`:3090 项全过;`tsc --noEmit`、`eslint`、`prettier --check` 均通过

复查本 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 <noreply@anthropic.com>
@cyfung1031

Copy link
Copy Markdown
Collaborator Author

按要求对本 PR 引入/新增的算法逐一做了时间/空间复杂度复查(`feat/ai-001` `7e7c1f2c`)。范围是本分支相对 `main` 实际新增或修改的非测试代码(`tool_loop_orchestrator_base.ts`、`context_elision.ts` 等新文件,以及 `background_session_manager.ts`、`cat_agent.ts`、`execute_script.ts` 等改动文件),跳过纯移动未改逻辑的代码。

已修复

1. tool_loop_orchestrator_base.ts — 工具调用结束后回写 toolCalls 状态

原实现每一轮工具调用结束后都会:

  • `getMessages(conversationId)`:整份读出该会话的持久化历史(OPFS JSON 文件,O(n) 读取 + 反序列化);
  • 反向线性扫描该历史,用嵌套 `toolCalls.some(...)` 逐条匹配目标 assistant 消息;
  • `saveMessages(conversationId, allMessages)`:整份覆写回文件(O(n) 序列化 + 写入)。

跨一整个多轮 tool-calling 对话(R 轮、历史长度随轮次线性增长到 N),这部分是 O(R·N) 的编排器侧冗余工作(不含存储层本身必要的 O(n) 读写)。

根因是:`appendMessage` 落库时其实已经构造出了带 `id` 的完整消息对象,但代码没有保留这个引用,之后要更新状态时只能重新整份读回、按内容匹配、整份写回。修复:保留该对象引用(`persistedAssistantMessage`),工具执行完成后直接 `chatRepo.updateMessage(persistedAssistantMessage)` 按 `id` 更新——去掉了编排器侧那份重复的全量消息拷贝和逐条 `some()` 匹配(`updateMessage` 内部仍是 OPFS 文件的整份读写,这是存储层本身的限制,不在本次改动范围内)。同时内存态 `messages` 里查找目标 assistant 消息,从正向 `Array.prototype.find`(O(n) 全量扫描)改成了从尾部反向扫描——目标消息就是本轮刚 push 的那条,实际总在数组尾部附近,反向扫描把摊还成本降到接近 O(1)。

2. context_elision.ts 的 `elideUntilWithinBudget`

原代码先算 `hasToolResults = messages.some(...)`(O(n)),再分两条 `if` 判断 `!hasToolResults && estimate()...` / `hasToolResults && estimate()...`——但这两个分支条件完全相同,`hasToolResults` 的值根本不影响任何分支行为,纯属多余的一次 O(n) 扫描。合并为一次判断,语义不变。

复查过但判断不适合在本 PR 改的

  • `estimateRequestTokens`(context_elision.ts:53)在 tool loop 每轮都做一次 `JSON.stringify(messages)`:由于 `messages` 每轮增长,一整个对话下来是 O(n²) 字节处理。要降到 O(n) 需要维护增量字节计数(每次 push/裁剪消息时更新一个运行中的字节总数),但这会牵涉多处消息增删(工具结果、裁剪、compact)都要同步维护计数,属于架构级改动,风险和改动面明显超出这个以 bug 修复为主的 PR 范围,本次不动,仅记录在案。
  • `preloadSkillsFromHistory`(chat_service_base.ts:723)每轮全量重扫历史找已加载的 skill:确认这段逻辑是从 `main` 的 `chat_service.ts` 原样搬过来的(`git show origin/main:.../chat_service.ts | grep preloadSkillsFromHistory` 可查到同名同逻辑函数),不是本 PR 引入的新代码,不在这次复查改动范围内。
  • `tool_call_guard`、`background_session_manager`、`cat_agent_base` 里的 `.find()` 调用:都被单轮 tool-call 数量(个位数)限制,不随会话规模增长,影响可忽略。
  • `execute_script.ts` 的 `buildResultPayload` 二分截断:范围受 `MAX_RESULT_CHARS`(30k)限制,不随历史增长。
  • `web_search.ts` 结果集固定 ≤10 条,非算法瓶颈。

验证

  • `npx vitest run`:3090 项全过(含为 `updateMessage` mock 语义新增/调整的用例)
  • `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 项全过

之前复查标记为"值得做但先搁置"的架构性问题: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 <noreply@anthropic.com>
@cyfung1031

Copy link
Copy Markdown
Collaborator Author

按要求把之前"值得做但先搁置"的架构性问题落实了(`feat/ai-001` `58c7eb9d`)。

`estimateRequestTokens`:按消息增量缓存序列化字节数

问题context_elision.ts 的 `estimateRequestTokens` 每次调用都对整段 `messages` 做一次 `JSON.stringify`。tool loop 每轮 preflight 都要调用它(tool_loop_orchestrator_base.ts),R 轮下来是 O(R·n),随对话增长趋近 O(n²)。真实场景下 tool 结果(页面内容、搜索结果摘录等)常见到数十 KB,这是长对话里真实的性能热点。

实现:按 message 对象身份缓存其 `role`/`content`/`toolCallId` 的序列化字节数(`WeakMap`)——这三个字段一旦写入基本不再变化。唯一会原地改写 `content` 的两处(`elideOldToolResults`、`elideOldAttachments`)都显式调用新增的 `invalidateMessageByteCache()` 使缓存失效。`toolCalls` 字段(`status`/`attachments`/`subAgentDetails` 会在工具执行后原地变化)刻意不缓存,每次单独重算——但它的体积只随"该条消息自身的工具调用数"增长,不随对话长度增长,代价很小。

为什么没有做成完全 O(n) 总量:也评估过按数组引用维护一个"只加新增项"的运行总量缓存(类似 tool_loop_orchestrator_base.ts 那次改动的思路),但会和 `toolCalls.status` 原地变化的正确性要求冲突——同一个 messages 数组、同一个长度,工具执行前后再次调用必须能读到最新状态,而"只加新增项"的设计一旦某条消息被计入运行总量就不会再重新访问它。要安全实现需要在 `applyToolUpdates` 等多处新增跨文件失效钩子,改动面和引入新 bug 的风险明显超过收益,因此没有采用。当前方案已经消除了真正昂贵的部分(大文本反复序列化),剩下的按条求和本身是廉价操作(Map 查找 + 加法)。

基准验证(20KB/tool 结果,模拟真实页面内容/搜索结果量级):

轮次 消息数 旧实现(整段重新 stringify) 新实现(按消息缓存) 加速比
100 200 100.8ms 6.8ms 14.9x
200 400 375.9ms 23.5ms 16.0x
400 800 1955.6ms 108.6ms 18.0x
800 1600 9018.5ms 397.8ms 22.7x

加速比随对话变长持续提升,符合"消除了随 n 增长的重复序列化成本"的预期。

新增测试覆盖三类关键正确性场景(context_elision.test.ts):`elideOldToolResults`/`elideOldAttachments` 改写 content 后估算值立即反映新内容而非陈旧缓存;`toolCalls.status` 原地变化后估算值能读到最新状态;反复调用同一未变化的消息返回稳定一致的结果。

`preloadSkillsFromHistory`:复核后判断不值得做架构改动

再评估了一下。它确实每轮都要 O(n) 扫描历史找 `load_skill` 调用(因为每轮的 `sessionRegistry` 都是全新构建的,为保证并发隔离——历史里记录过哪些 skill 被加载过,必须每轮重新得知才能把动态工具重新注册进这个新对象里,这部分 `执行` 本身无法省略)。可省的只是"扫描历史"这一步,做法是维护一个按 `conversationId` 的增量缓存(只扫描上次以来的新消息)。

没有动它的原因:这个循环本身极便宜——只是对轻量对象做 `msg.role === "assistant" && msg.toolCalls` 判断,不涉及大字符串序列化,即使历史有几千条消息,耗时也在亚毫秒级,和 `estimateRequestTokens` 那种"反复重新序列化数十 KB 文本"的量级完全不是一个数量级。而要安全实现跨轮次持久化的增量缓存,需要在 Service Worker 这种长期存活的上下文里新增一套"何时清理某个 conversationId 缓存"的生命周期管理——做不好就是真实的内存泄漏,风险比它要解决的(本就很小的)耗时问题更大。这是权衡后判断"改了收益不够、且引入新风险",不是漏看。

验证

  • `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 项全过

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 <noreply@anthropic.com>
@cyfung1031

Copy link
Copy Markdown
Collaborator Author

已在 `feat/ai-001`(`2777ceaa`)针对本轮 review 逐条修复:

  1. SSE 流 abort 时悬挂(High)content_utils.ts 的 `readSSEStream()` 在 abort 时不再静默 `return`,改为主动 `reader.cancel()` 并 reject,让循环内 `reader.read()` 卡住时也能被唤醒。anthropic.ts 补上了缺失的终态兜底(流结束但没收到 `message_stop`/`message_delta(usage)`/`error` 时补发 error)。llm_client.ts 的 `callLLM()` 额外订阅 signal 作为最后一道保险。三处叠加,确保 `LLMClient.callLLM()` 的外层 Promise 在任何取消场景下都会 settle。

  2. 取消终态双主体竞态(High)background_session_manager.ts 的 `stop()` 不再自行广播终态事件(只置 `cancelling` 并 `abort()`);终态事件的唯一来源改为 orchestrator 的 `emitCancelled()`(携带完整 usage/耗时)。同时在 tool_loop_orchestrator_base.ts 里把之前遗漏的几个 abort 出口(`callLLM`/`toolRegistry.execute`/`autoCompact` 因 abort 而 reject、guard 等待用户回答期间 abort、最终消息落库后)都统一收口到 `emitCancelled()`,不再出现"这条路径直接 throw 跳过终态化"或"误报 done"的情况。

  3. compact 在 Stop 后仍完成破坏性覆写(High)opfs_repo.ts 的 `writeJsonFile()` 新增可选 `signal`,利用 OPFS `createWritable()` 本身的事务性(`write()` 只写临时副本,`close()` 才原子替换旧文件)——signal 在 `close()` 落定前 abort 时改为 `writable.abort()` 放弃这次整份覆写,不提交、旧内容保持完整。`AgentChatRepo.saveMessages` 透传 signal;compact_service.ts 与手动 compact 都改为写盘确认成功后才允许覆写内存中的 `currentMessages`,不再"写盘失败但内存已被摘要顶替"。

  4. 同一会话并发写入无锁(High):复用仓库已有的 `stackAsyncTask`(async_queue.ts,未引入新依赖)按 `conversationId` 排队。chat_service_base.ts 把 `handleConversationChat` 拆成快速拒绝层 + 排队执行层,chat/compact(复用同一入口)与 `clearMessages` 现在共用同一把按会话排队的锁,不会再互相用过期快照覆盖对方的持久化写入。ephemeral 会话不读写 chatRepo,跳过排队。已有的"后台会话正在运行"快速拒绝仍在排队之前完成,不会让重复请求卡在队列里空等。

  5. 连接生命周期收尾(Medium)cat_agent.ts 的 `processStream()` 收到终态 `sync` 快照后立即 `disconnect()`;`processStreamEphemeral()` 的迭代器补上 `return()`/`throw()`,转发给内层迭代器并在提前退出时提交已累积的部分输出,避免下一轮 `chat()` 因丢失这部分历史而上下文断裂;两处 `executeTools` 结果发送前都检查连接是否已 settled 并用 try/catch 包裹 `sendMessage`,避免用户脚本上下文出现 unhandled rejection。

  6. token 估算器诚实化(Medium)context_elision.ts 的 `estimateRequestTokens()` 不再对文本/JSON 与图片字节数使用同一个折算系数——图片的 provider 计费机制(OpenAI 按分块、Anthropic 按宽×高/750)与 base64 字节数没有对应关系,本仓库也没有图片宽高元数据,无法套用具体公式,因此图片部分不做任何折算(宁可偏保守拒绝,不假装精确换算导致低估);函数注释也改为明确声明这是启发式估算、不是任何 provider 的精确 tokenizer(接入 tiktoken 等官方计数器需要新增依赖,超出本次改动范围,未采用)。

验证

  • `npx vitest run`:3099 项全过,新增用例覆盖 SSE abort 时 reject(open/anthropic/agent 三处)、stop() 不再自行广播、`writeJsonFile` 取消安全(abort 后旧内容不被覆盖)、同一 conversationId 的 chat/compact/clear 严格排队而不同会话互不阻塞
  • `npx tsc --noEmit`、`eslint`、`prettier --check` 全部通过
  • `npx playwright test`:`agent-conversation`、`agent-error-handling`、`agent-chat`、`agent-navigation`、`agent-provider`,共 13 项全过

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 <noreply@anthropic.com>
@cyfung1031

Copy link
Copy Markdown
Collaborator Author

已在 `feat/ai-001`(`40ad4b0e`)针对本轮 review 逐条修复:

  1. finalizeCancelled 清理漏判(High)background_session_manager.ts 的 `emitCancelled()` 广播的终态事件会先经过正常的 `sendEvent → updateStreamingState` 把 `status` 从 `cancelling` 改写成 `error`,而 `finalizeCancelled()` 原本只认 `cancelling`,导致这个真实存在的时序下永远不会调度 `cleanupIfDone()`,记录永久留在 `runningConversations`。改为 `cancelling` 或已经落定的终态都可以安全调度清理(`cleanupIfDone` 本身按实例比对 + status 二次确认,幂等安全)。`cancelling` 阶段的迟到 attach 也不再立即报 `error` 断开:sync 快照对外仍按 `running` 处理并继续订阅 listener,直到 orchestrator 真正广播出那条带完整取消原因/usage/耗时的终态事件才断开,避免丢失这条事件、也避免客户端在取消记录落库前就重载。

  2. 多个终态分支未在持久化后复查取消(High)tool_loop_orchestrator_base.ts 里 loop-guard 用户选择 Stop 的路径、超过 `maxIterations` 的路径、`emitContextTooLarge()` 都补上了信号复查——持久化期间/之后已被 Stop 时改走统一的取消终态化路径,不再对外报告 `done`/`max_iterations`/`context_too_large`。`emitCancelled`/`emitContextTooLarge` 的落库也都改为 try/catch 包裹,落库失败不再阻塞终态事件发送(此前 `emitCancelled` 是 `await appendMessage()` 后才发事件,写失败客户端就永远收不到终态)。

  3. abort 时 provider 侧图片落盘/usage 未妥善处理(Medium)llm_client.ts 的图片保存/markdown 内联图片提取流程改为逐步检查 `settled`,一旦外层 Promise 已经因 abort 落定就停止继续保存/发送 `content_block_complete`,并清理这一轮已经落盘但用不上的孤儿附件。openai.ts/anthropic.ts 在 abort reject 时把已知的部分 usage(chunk 级 usage / `message_start` 的 input+cache usage)带在错误对象上,orchestrator 取消前会把这部分并入累计 usage,不再直接丢弃。

  4. EOF 截断被当作成功完成(Medium):OpenAI 解析器现在跟踪是否见过 `finish_reason`,reader EOF 但既没有 `[DONE]` 也没见过 `finish_reason` 时按未预期断连报错,不再把可能截断的部分内容当作完整答案持久化,与 Anthropic 解析器的对应处理保持一致。

  5. 图片 token 估算严重失真(Medium)context_elision.ts 里 base64 字节数不再按 1:1 当 token 数——一张普通 100KB 照片按此前的估算会膨胀到十几万 token,直接撑爆未配置 `contextWindow` 模型(128K)的输入预算。改为按更贴近真实 provider 计费量级、但仍保守的固定系数(40 字节/token,注释里附了 OpenAI/Anthropic 的真实计费量级作为依据)折算。精确的按图片宽高计费(Anthropic 的 宽×高/750 公式)仍是已知的后续待办——需要在附件加载阶段解码图片尺寸,超出本次改动范围,已在提交信息里记录。

  6. ephemeral 提前退出留下悬空 tool_call 协议状态(Medium)cat_agent.ts 的 `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` 队列(chat_service_base.ts)只能覆盖同一个执行上下文内的并发,无法覆盖跨上下文的并发写入。真正的修复需要 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 项全过

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 <noreply@anthropic.com>
@cyfung1031

cyfung1031 commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author

补充一轮修复(commit cd3c79db,已推送到 feat/ai-001),针对本轮 review 的 12 项 findings:

已修复

  1. SSE reader 提前终态未释放(Low #12):readSSEStream()onEvent 判定为终态提前 return 时不再遗留未读完的 body/reader lock,finally 里识别该场景后主动 reader.cancel()。(content_utils.ts

  2. abort 安全网抢占带 usage 的真实 reject / error 事件丢字段(Medium #7):LLMClient 的 abort 安全网改为延迟到下一个宏任务 reject,让 parser 自身携带部分 usage 的 reject 优先落定;case "error" 保留 usage/errorCode/durationMs,不再转成裸 Error。(llm_client.ts

  3. 生成图片孤儿附件清理遗漏(Medium #8):改为记录本轮所有已落盘的附件 id,settled 时统一清理整轮附件,不再只清理取消瞬间"正在保存"的那一个。(llm_client.ts

  4. emitContextTooLarge 落库后未复查取消(Medium #5):持久化占位错误消息后补上信号复查,命中则走统一取消终态路径而不是继续对外报告 context_too_large。(tool_loop_orchestrator_base.ts

  5. 自动 compact 落盘后未复查取消(High #3):saveMessages() 成功后补 throwIfAborted,Stop 落在写盘之后、覆盖内存/广播 compact_done 之前时中止。(compact_service.ts

  6. Options UI 发现/生命周期竞态(High #6):listIds() 纳入 cancelling 状态;stopGeneration() 不再立即断开连接或置 isStreaming=false,改为等真正的终态事件/连接断开触发;sendMessage/attachToConversationonMessage 在 abort 后仍放行终态事件(只抑制中间流式增量);handleStop() 不再自行处理排队消息/重新加载历史,交由 onDone 在终态落定后统一处理,避免排队消息丢失。(hooks.ts, ChatArea.tsx, background_session_manager.ts

  7. executeTools 批次内不感知 settle(Medium #9):串行 handler 循环在每次迭代前检查连接是否已 settle(Stop/超时/断开),命中则把剩余 toolCall 标记为取消,不再继续执行。(cat_agent.ts

  8. 最终成功回复静默丢失持久化失败(Medium #10):正常完成分支的 appendMessage 加有限重试(3 次,200ms/400ms 退避),仍失败则发结构化 errorerrorCode: persist_failed),不再对外报告 done。(tool_loop_orchestrator_base.ts

  9. 输出 token 预留为 0(Medium #11):getReservedOutputTokens() 对未显式配置 maxTokens 的 OpenAI 兼容模型不再预留 0(这类请求会省略 max_tokens,provider 侧套用它自己的非零默认值),统一用保守默认值兜底。(model_context.ts

  10. 工具附件保存未感知取消(High #4,部分):saveAttachments() 接受 signal,每次写入前检查;内置工具与脚本工具两条路径均已传入,abort 时停止继续写入并把该 toolCall 标记为 error。(tool_registry.ts

评估后本次未处理

  • #1 仓库层非事务性写入#2 队列非连接感知/不可重入:真正的修复需要 navigator.locks 覆盖仓库层所有写方法,并对"活跃 tool handler 内发起新操作"(如 conv.clear())设计明确的重入策略——这是独立的一轮架构改动,且 happy-dom 测试环境下 navigator.locks 不可用,需要额外的环境探测兜底,不适合塞进这一轮。
  • #4 task 工具的持久化回调:仍缺少取消感知,与上面同理留作后续。

验证

  • npx vitest run:290 个文件、3115 个测试全部通过(含本轮新增/调整的针对性用例)
  • 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 e2e/agent-navigation.spec.ts e2e/agent-provider.spec.ts:13 项全过
  • pnpm run build:Rspack 编译成功

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] 建议暴露 AI Agent 最大工具调用限制并优化多轮 Tool Calling 下的 Token 消耗

1 participant