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 结果为准。 |
修复的缺陷1. 小上下文窗口模型的输入预算塌缩为 0,对话必然失败(High)
2. 定时任务绕过本 PR 新增的会话队列锁,可与 UI 对话并发丢消息(Medium-High) 本 PR 为 chat / compact / clearMessages 引入了按 行为不变的结构修复这三处是历次 review-fix 迭代留下的"基类 + 覆盖层"三明治,与仓库准则(直接替换而非适配层、不留死代码)冲突,且已经产生实际漂移: 3. 4. 5. 6. Settings 页原地重复声明 复查确认无误、未改动的点
已知边界(有注释记录,保持现状)
|
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>
|
已逐项核对本轮审计报告中的 8 个 findings,均确认在当前代码中真实存在后再修复,没有直接照报告修改。全程按 TDD 进行,共新增 22 条回归测试,并拆分为 8 个本地提交,每项一个提交。 High1. 仓库层跨上下文写竞争,读取失败可能覆盖旧数据提交: Options 页和 SW 会同时读写同一份 OPFS JSON,而 修复内容:
没有把 Options 页整体改为经 SW 转发,因为仓库层加锁已经能解决该数据丢失问题。 2. 队列未处理连接状态,且存在重入死锁提交: 确认断开的端口在注册 修复内容:
新增 4 条测试,覆盖排队期 Stop、排队期断开、死端口入口,以及重入 clear 的拒绝与恢复。 3. Stop 与 compact 的
|
|
本次已将本地加固提交推送到 PR #1549,主要改动:
验证结果: |
…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>
|
本次提交修复了本 PR 审计报告中列出的全部 8 项问题:
每项修复均补充了对应的回归测试,全量测试套件(3493 个测试)与 🤖 由 Claude Code 生成 |
这些引用只在本轮审计对话里有意义,脱离上下文后既不利于阅读代码, 也无助于代码评审或后续维护。保留每条注释里真正解释"为什么"的实质 内容,只删除指向内部编号的追溯性标注;测试用例标题同理,只去掉 "【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>
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。