Skip to content

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

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

Agent 对话可配置最大工具调用次数 + Token 优化 + 循环检测升级#1549
cyfung1031 wants to merge 65 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 结果为准。

@cyfung1031

Copy link
Copy Markdown
Collaborator Author

修复的缺陷

1. 小上下文窗口模型的输入预算塌缩为 0,对话必然失败(High)

getReservedOutputTokens() 在未配置 maxTokens 时固定预留 16384 输出 token,叠加 10% 安全边际后,凡 contextWindow ≲ 18K 的模型——前缀表内置的 gpt-4(8192)、gpt-3.5(16385)、phi(16000),以及用户为本地小模型自定义的小窗口——getInputTokenBudget() 直接返回 0。编排器的 preflight 预算检查随即对任何请求判定 context_too_large,这类模型的对话从第一条消息就失败。修复:默认预留改为 min(16384, 窗口/4)(显式配置的 maxTokens 行为不变),并补充回归测试;两个依赖旧预算公式魔法数字的既有用例(autoCompact 触发、手动 compact 裁剪)同步调整了参数,用例意图不变。

2. 定时任务绕过本 PR 新增的会话队列锁,可与 UI 对话并发丢消息(Medium-High)

本 PR 为 chat / compact / clearMessages 引入了按 agent-chat:<conversationId> 的队列锁,但 executeInternalTask() 不在锁内:appendMessage 是"读出整份 JSON → push → 整份写回",续接已有会话(task.conversationId)的定时任务与正在进行的 UI 对话并发时,双方的写入会互相覆盖。修复:导出共享的 conversationChatLockKey(),定时任务执行体排入同一队列(task_service.test.ts 新增两条用例验证串行语义)。

行为不变的结构修复

这三处是历次 review-fix 迭代留下的"基类 + 覆盖层"三明治,与仓库准则(直接替换而非适配层、不留死代码)冲突,且已经产生实际漂移:

3. cat_agent.ts 直接改写 cat_agent_base.ts 的 prototype——mergeTools / executeTools / processChat / processStream / processStreamEphemeral 五个方法在运行时被整体替换,基类里同名实现是永远不会执行的死代码,并且已与真实实现漂移(基类 mergeTools 不替换同名工具 schema、processStream 缺 requestId 批次关联、缺终态主动断开与 return()/throw() 处理等——阅读基类会得出与运行时相反的结论)。已合并为单文件单类,方法签名如实反映运行时行为(chatStream 产出 StreamChunkattach 产出含 sync 快照的 ConversationStreamChunk,与 scriptcat.d.ts 的公开声明一致);cat_agent.test.ts 全部 83 个用例无改动通过,证明合并前后行为一致。

4. chat_service.tsparams: any 子类 + Proxy 包装 sender 来叠加 executeTools 的 requestId 关联。已合并进单一 ChatService:连接包装收敛为 wrapScriptToolConnection(),入口处包装一次并显式向下传递 MessageConnect,去掉 Proxy 与 any。

5. tool_loop_orchestrator.ts 包装类每次调用重新 new 基类实例,并无条件覆盖调用方传入的 inputTokenBudget,导致基类的 inputTokenBudget === undefined 分支在生产路径永远不可达。已合并为单类:未传入时在循环内部按模型推导。

6. Settings 页原地重复声明 MIN/MAX_CHAT_MAX_ITERATIONS,改为复用 agent_config.ts 的导出,消除双份边界常量的漂移风险。

复查确认无误、未改动的点

  • Anthropic cache_control 断点只写在每次请求新建的转换对象上(tool_result / tool_use / convertContentBlocks 均为新建),不会在共享消息对象上跨请求累积断点。
  • 取消/终态链路(emitCancelled 唯一终态事件、cancelling 占位、finalizeCancelled 幂等收敛、SSE abort reject、部分 usage 回收)在各路径上语义一致。
  • toLLMMessages 统一过滤错误占位消息,chat / compact / 定时任务三条重建路径行为一致。
  • 8 个 locale 的 9 个新增文案 key 均已齐全。

已知边界(有注释记录,保持现状)

  • writeJsonFile 的 abort 只保证 close() 发出前不提交,close() 进行中的极窄窗口仍可能提交(opfs_repo.ts 注释已如实说明,调用方事后复查 signal 兜底)。
  • Settings 的"最大工具调用次数"输入沿用页面统一的"修改即时保存"模式;清空输入框会被立即钳位为 1,输入体验略糙,但与同页其它字段行为一致,未单独改造。
  • 后台会话注册表的 30s 延迟清理基于 setTimeout,MV3 Service Worker 提前休眠时清理会随 SW 一起丢弃(重启后注册表本身为空,无泄漏后果)。

cyfung1031 and others added 8 commits July 16, 2026 23:25
catch 分支之前只在 abort 时合并错误上挂载的本轮部分 usage,非取消的
provider 失败(如流截断)会用上一轮累计直接覆盖错误自带的 usage,
本轮已消耗的 token 从持久化错误、定时任务与子代理累计中丢失。
现统一先合并再分流,与取消路径同一记账口径,不重复计数。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
模型生成的图片先落 OPFS、再由 assistant 消息持久化引用。此前 LLM 返回
之后的取消、autoCompact 失败、引用消息落库失败(persist_failed)等
退出路径不会删除已保存的附件,产生无引用的孤儿文件。现引用消息落库
成功才算所有权转移,否则统一回收本轮生成附件。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- 编排器在 tool 结果事件发送与持久化(均为 await)之后重新检查实时
  signal,不再只依赖工具执行结束时的一次性采样,避免带着已取消的
  信号继续循环检测和下一轮 LLM 调用。
- saveAttachments 在每次写入后也检查 signal;取消/失败时整批回收本批
  已落盘的附件,不把结果按成功上报(该 toolCall 以 error 收场后这些
  文件已无引用)。
- 任务持久化回调透传 AbortSignal 直至 OPFS writeJsonFile,Stop 后
  不再提交任务快照。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
writeJsonFile 的 signal 只保证 close() 发出前的 abort 不提交;abort
恰好落在 close() 进行期间时摘要仍可能写入成功,用户收到"已取消",
磁盘历史却已被摘要顶替。手动与自动 compact 现均在写入前快照持久化
历史,事后发现取消即原样回写,保证对外结果与磁盘内容一致。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Options 页与 Service Worker 都会直接读写同一份 OPFS JSON(会话列表、
消息历史),SW 内的会话队列覆盖不了跨上下文的读-改-写竞争,双方基于
同一旧快照互相覆盖丢数据。AgentChatRepo 的读-改-写现统一在
navigator.locks(按 origin 全局生效)的排它锁内执行,测试环境退化为
进程内按 key 排队。

readJsonFile 只在文件不存在(NotFoundError)时返回默认值;解析/权限/
I/O 失败一律抛出——此前任何读取失败都静默转成默认值,随后的 append/
save 会基于空快照把仍然存在的旧数据整份覆写掉。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- 连接回调(stop/askUserResponse/toolResults/断开)改为排队之前注册:
  断开的端口上注册回调会直接抛错,之前该抛错发生在后台 rc 已插入之后、
  主 try 之前,记录会永久卡在 running;排队等待期间到达的 Stop/断开
  此前也会被整体丢失。
- 临界区入口先观察排队期间的 Stop/断开:被停止的请求以取消终态收尾、
  不再调用 LLM;后台占用在入锁后复查一次。
- 会话等待脚本工具结果期间的 clearMessages 显式拒绝(fail fast):
  该窗口内 chat 持有会话队列锁等待 toolResults,工具 handler 内部
  await conv.clear() 照常排队会形成相互等待死锁;其余时刻保持原有的
  排队等待语义。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SW 端脚本工具批次超时后合成错误结果继续推进对话,但客户端适配器只按
会话级 settled 判定,仍会串行执行过期批次的剩余 handler,其副作用与
下一批次并发交叠。requestId 只能拦截过期的结果回传,拦不住副作用。
超时现向客户端发送 cancelToolBatch(包装层补当前批次 requestId),
客户端将该批次标记作废并跳过剩余 handler。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
视觉计费由 provider 规则与图片尺寸决定,与压缩后字节数无线性关系:
高度可压缩的大图按字节换算会被严重低估,噪点图会被高估,而这一估算
被用作 preflight 硬性准入。loadAttachmentSizes 现对图片附件用
createImageBitmap 解码尺寸(SW 原生支持),估算按 宽×高/750(长边
1568 等比缩放,取更保守的 Anthropic 公式,85 下限);解码失败或环境
不支持时退回原有的保守字节换算。

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

Copy link
Copy Markdown
Collaborator Author

已逐项核对本轮审计报告中的 8 个 findings,均确认在当前代码中真实存在后再修复,没有直接照报告修改。全程按 TDD 进行,共新增 22 条回归测试,并拆分为 8 个本地提交,每项一个提交。

High

1. 仓库层跨上下文写竞争,读取失败可能覆盖旧数据

提交:ded6c36b

Options 页和 SW 会同时读写同一份 OPFS JSON,而 appendMessagesaveConversation 等操作都是整文件“读-改-写”,原有 SW 队列无法避免跨上下文竞争。

修复内容:

  • 所有仓库层读改写操作统一放入 navigator.locks 排它锁中,包括 conversations、messages、tasks 和删除操作。
  • 不支持 Web Locks 的环境,退化为进程内按 key 排队。
  • readJsonFile 仅在 NotFoundError 时返回默认值;解析、权限和 I/O 错误全部向上抛出,避免“读取失败后用空数据覆盖原文件”。
  • 新增并发写入测试,修复前可稳定复现 lost update。

没有把 Options 页整体改为经 SW 转发,因为仓库层加锁已经能解决该数据丢失问题。

2. 队列未处理连接状态,且存在重入死锁

提交:8cc266a8

确认断开的端口在注册 onMessageonDisconnect 时会直接抛错。旧逻辑又是在任务标记为 running 后才注册监听,导致记录可能永久卡住;排队期间发生的 Stop 或断开也会丢失。

修复内容:

  • 连接监听提前到入队前注册;注册失败则直接判定端口已断开,不再入队。
  • 进入临界区时重新检查排队期间是否发生 Stop 或断开。
  • 获取后台锁后再次检查连接状态。
  • 会话等待脚本工具结果期间,clearMessages 明确拒绝执行,并提示先停止会话;其他情况仍保持原有排队语义。

新增 4 条测试,覆盖排队期 Stop、排队期断开、死端口入口,以及重入 clear 的拒绝与恢复。

3. Stop 与 compact 的 close() 提交窗口冲突

提交:32af66af

确认取消发生在 close() 期间时,摘要仍可能落盘,最终出现“用户看到已取消,但磁盘内容已被摘要替换”的情况。

修复后,手动和自动 compact 都会:

  • 写入前保存原始历史快照。
  • 写入后再次检查取消状态。
  • 如果已取消,立即回写原历史。

没有引入 revision CAS,因为 SW 会话队列和仓库层锁已经保证写入串行;在此前提下,快照回滚更简单,也足够安全。

4. 取消后仍可能显示成功并继续产生副作用

提交:6be9ea9e

修复了三处:

  • 工具结果事件、持久化和状态回写完成后,重新读取实时 AbortSignal,不再依赖工具执行结束时的单次状态。
  • saveAttachments 每次写入后检查取消状态;取消或失败时,删除本批已写入但尚未被引用的附件。
  • 任务持久化回调将 AbortSignal 一直传递到 writeJsonFile

工具已经实际执行完成的结果仍会持久化,避免刷新后工具状态一直显示处理中;但会话终态会正确标记为 cancelled,且不再进入下一轮。

Medium

5. 生成附件可能变成孤儿文件

提交:59248ce0

生成附件现在按“本轮租约”管理。只有引用该附件的 assistant 消息成功落库后,附件才正式转移所有权。

以下退出路径会统一清理本轮生成附件:

  • LLM 返回后立即取消
  • autoCompact 失败
  • 引用消息落库失败
  • 其他未成功持久化消息的退出路径

新增 2 条回归测试,覆盖立即取消和 persist_failed

6. 服务端批次超时后,客户端仍继续执行

提交:3da60995

服务端超时后,现在会向客户端发送 cancelToolBatch,并携带当前批次的 requestId

客户端会将该批次标记为失效,串行队列中尚未执行的 handler 直接跳过,避免旧批次副作用与下一批次重叠。

本轮没有给公开 handler API 增加 AbortSignal,该项可后续单独处理。

7. Provider 非取消失败时丢失本轮 usage

提交:ef96056d

catch 分支现在会先将错误中携带的本轮部分 usage 合并到累计值,再区分取消或普通错误。

这样可以避免持久化错误、定时任务和子代理场景漏记本轮已消耗的 token。

8. 上下文准入估算不准确

提交:67fb81f0

图片改为按像素尺寸估算 token:

  • 使用 createImageBitmap 读取宽高。
  • 按缩放后像素面积估算,采用更保守的 Anthropic 公式,并设置 85 token 下限。
  • 解码失败或环境不支持时,退回原有字节估算。

文本和 JSON 仍使用保守的字节估算,即 2 字节/token。接入 tiktoken 或各 provider 官方计数器需要新增依赖和持续维护,不在本 PR 范围内。代码注释已明确说明该值是大致上界,不是精确计数。

Copy link
Copy Markdown
Collaborator Author

本次已将本地加固提交推送到 PR #1549,主要改动:

  • 修复会话取消、重复进入、删除/切换会话时的竞态,以及附件和临时资源清理问题。
  • 为 OPFS、chrome.storage、工具轮次和任务运行记录增加写入确认、CAS 校验和重试安全保障。
  • 完善工具调用取消、定时任务中止、事件派发和 Service Worker 重启恢复策略。
  • 修复历史工具结果恢复、compact、任务编辑冲突和发送失败时的界面状态。
  • 补充了对应的回归测试。

验证结果:pnpm lint 通过;pnpm test:ci 通过(310 个测试文件、3474 个测试);pnpm build 通过。

cyfung1031 and others added 10 commits July 17, 2026 19:06
…ar/save/attach and scheduled-task boundaries

Stale ConversationInstance handles (and scheduled tasks bound to a
conversationId) previously retargeted whichever generation currently
owned that id after a delete+recreate, silently applying chat/clear/
getMessages/attach to an unrelated conversation. Every persistent
request and the internal-task conversation binding now carries the
expected generation and is rejected when it no longer matches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…n read as proof of non-durability

writeJsonFileConfirmed() surfaces an error when both the write and its
read-back confirmation fail, even though the underlying write may have
committed. Three call sites treated that error as authorization to
delete attachments: the user-message append (provisional upload
attachments), the tool-round commit (tool + generated attachments),
and the final assistant message retry loop (generated images). Each
now performs one more explicit durability check — does the message
actually exist in the persisted snapshot — before deleting anything,
and only deletes once that check positively confirms the message was
never committed rather than merely failing to confirm either way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…m best-effort garbage collection

createConversation, deleteConversation, and saveMessages each committed
their authoritative state (metadata insert/delete, new message
snapshot) and then ran cleanup that could still throw (deleting a
reused ID's leftover files, a deleted conversation's messages/tasks/
attachments, or a history replacement's now-unreferenced attachments).
A cleanup failure surfaced as failure of the whole operation even
though the durable state had already changed, leaving retries stuck:
create collides with the record that already exists, delete finds no
metadata and silently no-ops without finishing cleanup, and
clear/compact reports failure after the replacement already committed.
Cleanup is now wrapped as best-effort so it can leave orphaned files
behind on failure without misreporting the primary operation's outcome.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…cy (pre-upgrade) conversations

ownedAttachmentIds is new and optional; conversations created before it
existed never populated it, so garbage collection (clear/delete/
compact/summary retention) only ever saw an empty owned set for their
messages and tool calls, and could neither release nor preserve the
attachments those legacy messages actually reference through content
blocks or ToolCall.attachments. Gate the inference on the
conversation's generation carrying the "legacy:" prefix stamped for
pre-upgrade records (agent_chat.ts's isLegacyGeneration, now shared via
persisted_messages.ts so it survives modules that mock agent_chat in
tests) rather than on the field being merely absent — an absent/empty
ownedAttachmentIds is also the normal, current-model way to say
"borrowed, not owned," and inferring from content blocks unconditionally
would misattribute ownership and cause borrowed attachments to be
deleted alongside an unrelated message.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ting its metadata

The delete handler awaited removeTask() (metadata delete + run-history
cleanup) before calling the scheduler's cancelTask(). A slow or failing
run-history cleanup let the deleted task keep making LLM calls, running
tools, and emitting side effects in the meantime, and if removeTask
threw, cancelTask was never reached at all — the execution was never
aborted. cancelTask() is a synchronous abort() with no cleanup of its
own, so calling it first stops the running execution immediately and
independently of whether metadata/run-history cleanup succeeds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…nput unmount

The unmount cleanup effect had an empty dependency array but read
attachments in its closure, so it only ever saw the initial empty
array from the mount render. Individual removal and successful sending
already revoked URLs correctly, but navigating away with files still
selected leaked their blob: URLs. Track the latest attachments in a
ref (updated via its own effect, since ESLint's react-hooks/refs rule
disallows mutating refs during render) and have the unmount cleanup
read from that ref instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… silently dropping them

LLMClient saved each provider-generated image content block and
silently ignored any failure, omitting the block from the result with
no other trace — an image-only response could resolve successfully
and billed with no image and potentially empty content. LLMCallResult
now carries an optional warning describing how many images failed to
save; the tool loop persists it onto the assistant message (visible
after reload) and broadcasts a system_warning event before done so the
UI shows it immediately, all without discarding the accumulated usage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…treating them as opaque api_error

The byte-count token estimator is a conservative heuristic that can
still underestimate against arbitrary provider-compatible models, and
the hard admission check trusts it to gate context_too_large before
ever calling the provider. When the estimate underestimates and the
provider itself rejects the request for exceeding its context window,
that error had no dedicated classification and fell through to the
generic "api_error" bucket. classifyErrorCode now recognizes the
common context-length-exceeded phrasing used by OpenAI/Anthropic and
compatible APIs and reports context_too_large, giving callers the same
recovery signal whether the local estimate or the provider caught the
oversized request. Full exact-token-count guarantees across arbitrary
models remain out of scope; this is the advisory-estimate fallback
path the finding calls for.

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

Copy link
Copy Markdown
Collaborator Author

本次提交修复了本 PR 审计报告中列出的全部 8 项问题:

  1. 会话 generation 未在 API 边界强制校验(高)chat/chatStream/getMessages/clear/save/attach 以及后台附加、定时任务续接现在都携带并校验会话 generation,会话被删除重建后,持有旧 generation 的调用会被拒绝,而不是静默作用到无关的新一代会话。
  2. 二义性提交被误判为附件未持久化,导致误删(高):用户消息追加、工具轮次提交、最终回复重试失败时,删除附件前会先读回确认消息是否确实未落盘,避免把"确认读也失败"当成"确定没写入"。
  3. 主提交与垃圾回收(GC)非失败原子(中)createConversation/deleteConversation/saveMessages 的善后清理(旧文件/附件删除)现在是尽力而为,清理失败不会再让已经提交成功的主操作报告失败。
  4. 升级前的历史会话附件被排除在所有权模型之外(中):为 generation 带 legacy: 前缀的旧会话补充按 content block / 工具附件元数据推断所有权,新模型下的正常"借用"语义保持不变。
  5. 生成图片持久化失败被静默吞掉(中)LLMClient 现在会把保存失败的信息作为 warning 写入消息并通过 system_warning 事件实时提示,不再让用户拿到一个"成功却丢图"的回复。
  6. 硬性上下文准入仍依赖不保证准确的 token 估算(中):新增对 provider 端"上下文超限"错误措辞的识别,分类为 context_too_large,为本地字节数估算低估的情况提供兜底恢复路径(跨任意 provider 的精确计数仍超出本次修复范围)。
  7. 删除运行中的定时任务时中止过晚(中):删除任务时改为先调用 cancelTask() 中止执行,再清理元数据和运行记录,清理失败也不会阻止任务被中止。
  8. ChatInput 卸载时预览 URL 泄漏(低):卸载清理逻辑改为读取 ref 中的最新附件列表,而不是挂载时闭包捕获的初始空数组。

每项修复均补充了对应的回归测试,全量测试套件(3493 个测试)与 tsc --noEmit 均通过。

🤖 由 Claude Code 生成

cyfung1031 and others added 2 commits July 17, 2026 21:26
这些引用只在本轮审计对话里有意义,脱离上下文后既不利于阅读代码,
也无助于代码评审或后续维护。保留每条注释里真正解释"为什么"的实质
内容,只删除指向内部编号的追溯性标注;测试用例标题同理,只去掉
"【finding N 回归】" 前缀,描述性文字本身保持不变。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
审查本 PR 引入的注释:删除只是复述紧邻下一行代码(如 "发送 done 事件"
+ sendEvent(...)、"继续循环" + continue)的注释;删除与函数 JSDoc
内容重复的方法体内说明(SessionToolRegistry 隔离性、load_skill 预
加载副作用);删除 tool_loop_orchestrator.ts 中一处描述已经错位、
不再对应下方代码的过时注释;修正一处中英混排的错别字
("先positively" -> "先明确")。保留的注释都解释了非显而易见的
"为什么"(并发/竞态不变量、二义性提交处理、附件所有权规则等),
未做改动。

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