Agent 对话可配置最大工具调用次数 + Token 优化 + 循环检测升级#1549
Conversation
针对 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>
This comment was marked as outdated.
This comment was marked as outdated.
|
已完成并推送到
验证:相关 5 个测试文件 95 个测试通过; |
|
已根据完整 本次补充修复了持久化历史裁剪与既有内存消息循环语义之间的边界:只有正常续接持久化会话和续接已有会话的定时任务启用首个 LLM 请求前的历史副本裁剪;ephemeral/direct tool-loop 调用继续保留原有消息数组行为。 最终验证:
|
|
已完成本轮问题修复并推送到
验证结果:
|
|
已继续修复并推送到
验证结果:
|
|
已完成本轮修复并推送到
验证结果:
|
|
已完成本轮修复并推送到
验证结果:
|
每轮 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>
|
本次推送修复了 code review 中发现的 7 个问题(均已本地验证:
提交记录: 🤖 Generated with Claude Code |
|
已按本轮审查 Findings 完成修复并推送到 主要修复:
验证:相关 TypeScript 文件已通过 Prettier 检查。当前受限执行环境无法解析完整项目的 Vitest 依赖,因此未宣称本地完整测试通过,后续以 PR CI 结果为准。 |
|
Implemented on
Verified with |
|
Pushed Summary:
Verified with |
|
已完成本次修复并提交
验证: |
- 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>
|
已在
全部改动已跑过 |
estimateRequestTokens 引入字节→token 保守折算后(见上一提交),命中目标 预算区间所需的字符数随之变大;原用例逐字符线性扫描长度、每次都做一次 O(len) 的 JSON.stringify,字符数变大后总耗时成倍增长,在 CI 上超过 340ms 的 fast 用例超时导致失败。 改为二分查找最小满足长度,迭代次数从 O(n) 降到 O(log n),用例总耗时从 超时降回个位数毫秒。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
CI 失败已修复( 根因:上一提交把 `estimateRequestTokens()` 从"UTF-8 字节数直接当 token 数"改成了"字节数按保守常数折算成 token 数",返回值整体变小。 修复:compact_service.test.ts 把逐字符线性扫描改成二分查找最小满足长度,迭代次数从 O(n) 降到 O(log n),测试逻辑不变,只是构造样本的方式更快。 验证:
|
上一提交在 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>
|
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` 守卫,本身不受影响。 本地复现与验证:
|
复查本 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>
|
按要求对本 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 状态 原实现每一轮工具调用结束后都会:
跨一整个多轮 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 改的
验证
|
之前复查标记为"值得做但先搁置"的架构性问题: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>
|
按要求把之前"值得做但先搁置"的架构性问题落实了(`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 结果,模拟真实页面内容/搜索结果量级):
加速比随对话变长持续提升,符合"消除了随 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 缓存"的生命周期管理——做不好就是真实的内存泄漏,风险比它要解决的(本就很小的)耗时问题更大。这是权衡后判断"改了收益不够、且引入新风险",不是漏看。 验证
|
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>
|
已在 `feat/ai-001`(`2777ceaa`)针对本轮 review 逐条修复:
验证
|
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>
|
已在 `feat/ai-001`(`40ad4b0e`)针对本轮 review 逐条修复:
评估后本次未实现(记录在案)finding 3(仓库层跨执行环境序列化)与 finding 4(队列的连接感知/重入策略):Options 页面、定时任务与 Service Worker 分属不同 JS 执行上下文,上一轮实现的进程内 `stackAsyncTask` 队列(chat_service_base.ts)只能覆盖同一个执行上下文内的并发,无法覆盖跨上下文的并发写入。真正的修复需要 Web Locks API(`navigator.locks`,跨上下文共享同一把源级锁)覆盖仓库层所有写方法(消息、任务、附件清理、会话元数据),并为"活跃 tool handler 内发起新操作"(如工具执行期间调用 `conv.clear()`)设计明确的重入策略——这是一次影响面大、需要独立测试与产品决策的改动,仓促实现风险明显高于收益,故未在本次改动中处理,留作后续独立的一轮。 验证
|
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>
|
补充一轮修复(commit 已修复
评估后本次未处理
验证
|
Checklist / 检查清单
背景
关联 #1545,解决内置 Agent 在长时间、高频 Tool Calling 场景中的三个问题:
实现内容
1. 可配置最大工具调用次数,并支持原会话继续执行
AgentConfigRepo,通过chrome.storage.local持久化chatMaxIterations:501–1000errorCode: "max_iterations"的错误消息;2. 降低长 Tool Loop 的 Token 消耗
Anthropic Prompt Cache
cache_control断点,使下一轮能够复用已经生成的消息前缀。execute_script大结果截断30000字符时,仅保留首尾各15000字符。truncated: true和original_length,并提示 Agent 缩小返回结果或将大数据写入 OPFS。Tool Result 滑动窗口裁剪
40%、60%阈值分批触发裁剪;达到60%后,每新增 5 轮工具调用再滑动一次窗口。80%以上仍优先使用现有 auto-compact 流程。3. Loop Guard 支持用户介入
tool_call_guard的模型提醒和system_warning展示。Continue:继续执行,并重置警告计数;Stop:持久化停止说明并以done正常结束,而不是报错。pendingAskUser状态。AbortSignal立即停止。兼容性
Test plan
npm run test:ci— 3041/3041 tests passed(289 个测试文件)npm run lint— prettier / tsc / eslint 全部通过调查过程与技术方案细节见 issue-1545-proposal.md。