Skip to content

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

Open
cyfung1031 wants to merge 6 commits into
scriptscat:mainfrom
cyfung1031:feat/ai-001
Open

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

Conversation

@cyfung1031

@cyfung1031 cyfung1031 commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

Implements the proposal for #1545 (Agent 内置 AI Agent 高频工具调用的痛点:迭代次数硬限制、Token 消耗过高、缺少死循环检测)。

  • 可配置最大工具调用次数:新增 AgentConfigRepochatMaxIterations,默认 50,可调 1–1000),Settings 页新增"对话"分类;达到上限时消息持久化 errorCode,聊天界面渲染"继续对话"按钮一键复用已持久化的完整历史继续执行(不再需要新建对话、不会丢失已探索的上下文)。
  • Token 优化
    • Anthropic provider 为最后一条消息追加 cache_control 断点,使长 tool loop 中已产生的历史前缀被缓存,后续每轮仅新增部分计费(现有 system/tools 断点 + 此断点共 3 个,未超过 4 个上限)。
    • execute_script 返回值超过 30000 字符时截断为首尾各 15000 字符并标注 truncated/original_length,避免超大返回值(如 DOM dump)被完整重复发送。
    • 新增滑动窗口裁剪:上下文占用达到 40%/60% 两个阈值时各触发一次,保留最近 5 轮 assistant/tool 消息原文,更早轮次的 tool 结果替换为占位文本;仅裁剪内存中传给 LLM 的消息,不影响持久化历史与 UI 展示;按阈值分批触发而非逐轮触发,避免频繁重写消息前缀导致 prompt cache 断点失效。
  • 循环检测升级:现有 tool_call_guard 检测到重复/循环模式时只向 LLM 注入提醒;新增 askUserForGuard 回调,连续命中达到 2 次时暂停循环、询问用户"继续"或"停止",停止则优雅收尾(done 而非 error)。仅 UI 对话(含后台会话,复用既有 ask_user 机制,5 分钟无人应答默认继续)传入该回调;定时任务与子代理保持原有的仅告警不暂停行为,避免无人值守场景被阻塞。

调查过程与技术方案细节见随本次改动附带的 issue-1545-proposal.md

Test plan

  • TDD:所有改动均先写失败测试(BDD 风格中文 describe/it),再实现
  • npm run test:ci — 全量 3033 个测试通过(289 个测试文件)
  • npm run lint — prettier / tsc / eslint 全部通过
  • 全部 8 个 locale(zh-CN、en-US、zh-TW、ja-JP、ru-RU、de-DE、vi-VN、tr-TR)补齐新增文案

🤖 Generated with Claude Code

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

Copy link
Copy Markdown
Collaborator Author

Thanks for the review — all 4 issues confirmed and fixed in de83fd3, each with a regression test written to fail first, then pass after the fix.

1. Blocker — empty error message replayed to LLM on Continue. Confirmed: buildAndPersistUserMessage copied every non-system persisted message verbatim, including the content: "" / error / errorCode: "max_iterations" message. Fixed by skipping any historical message with error set when rebuilding the LLM-facing context (it stays in the repo/UI for display, just isn't replayed). Regression test seeds history with a max-iterations error message, sends "continue", and asserts the outgoing request contains no empty-content assistant message.

2. Stale pendingAskUser after guard-timeout. Confirmed — the 5-minute timeout in askUserForGuard deleted the resolver but never cleared rc.pendingAskUser, so a later attach would sync a dead prompt. Fixed by clearing rc.pendingAskUser in the timeout callback when it still matches the timed-out askId. The regression test needed care: naturally letting the conversation finish masked the bug because the existing done handler also clears pendingAskUser, so the test now holds the 5th round's fetch pending forever and asserts pendingAskUser is already cleared while status is still "running" — i.e. checks the fix fires at timeout, not incidentally at completion.

3. Bounds enforced only in Settings UI. Confirmed — AgentConfigRepo stored/returned raw values, and chat_service.ts used params.maxIterations || agentConfig.chatMaxIterations (which also let a negative params.maxIterations slip through unclamped, since only 0/NaN/"" are falsy). Added normalizeChatMaxIterations (clamp to [1, 1000], round, fall back to default on non-finite/non-number) as the single source of truth, applied in both getConfig/saveConfig and exported for chat_service.ts to apply to the final resolved value (also switched to ?? so an explicit 0 clamps to 1 instead of silently falling back to the configured default). Tests cover corrupted storage (0, negative, oversized, NaN, string, null, non-integer) and a directly-supplied negative maxIterations.

4. Strike count never resets. Confirmed as described — agreed it was more aggressive than intended. Now resets to 0 after a "Continue" answer, so escalation requires 2 fresh hits again rather than pausing on every subsequent warning for the rest of the run. Test drives 8 rounds of repeated calls (4 hits total) and asserts the pause only fires on hits 2 and 4, not on hit 3.

Full verification after the fixes: npm run test:ci — 3041/3041 tests pass across 289 files (up from 3033, the 8 new regression tests); npm run lint (prettier + tsc + eslint) clean.

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.

1 participant