Skip to content

fix(agent): cap fork turns and bubble fork permission prompts#5737

Merged
qqqys merged 2 commits into
QwenLM:mainfrom
qqqys:fix/fork-turn-cap-and-permission-bubble
Jun 23, 2026
Merged

fix(agent): cap fork turns and bubble fork permission prompts#5737
qqqys merged 2 commits into
QwenLM:mainfrom
qqqys:fix/fork-turn-cap-and-permission-bubble

Conversation

@qqqys

@qqqys qqqys commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

What

Two robustness fixes for the detached fork subagent (subagent_type: "fork" / /fork), both stemming from the fork running fire-and-forget in the background with no inline UI. See #5734.

1. Cap fork turns

createForkSubagent launched the fork with {} as RunConfig, leaving max_turns unset — a fire-and-forget fork that fails to converge could loop and burn tokens with no upper bound. Adds FORK_DEFAULT_MAX_TURNS = 200 (matching claude-code's fork cap) and passes it to AgentHeadless.create.

2. Bubble fork permission prompts

FORK_AGENT used approvalMode: 'default', so in the background launch path shouldBubble was false and getShouldAvoidPermissionPrompts() returned true → any permission-gated tool call was silently auto-denied (the fork couldn't even run its boilerplate-mandated "commit before reporting"). Switches to 'bubble' so prompts surface to the parent's Background-tasks UI via the existing bridge. Non-interactive forks are already gated off (isForkSubagentEnabled = config.isInteractive()), so there is no headless regression.

Test

  • New agent.test.ts case: fork dispatch passes { max_turns: 200 } to AgentHeadless.create, and FORK_AGENT.approvalMode === BUBBLE_APPROVAL_MODE.
  • packages/core typecheck + tests green (159 passing); packages/cli typecheck green.

Closes #5734.

A detached fork subagent runs fire-and-forget in the background with no
inline UI. Two robustness gaps followed from that:

- It was launched with an empty RunConfig (`{} as RunConfig`), so its
  reasoning loop had no max_turns cap and could burn tokens unbounded.
  Add FORK_DEFAULT_MAX_TURNS=200 and pass it to AgentHeadless.create.

- FORK_AGENT used approvalMode 'default', so in the background launch
  path shouldBubble was false and permission-gated tool calls were
  silently auto-denied (the fork couldn't even run its own "commit
  before reporting"). Switch to 'bubble' so prompts surface to the
  parent's Background-tasks UI; non-interactive forks are already gated
  off, so there is no headless regression.

Closes QwenLM#5734.

Co-authored-by: Qwen-Coder <[email protected]>
// Turn cap for a detached fork — fire-and-forget background work nobody awaits,
// so an unbounded reasoning loop burns tokens silently. Matches claude-code's
// fork cap of 200.
export const FORK_DEFAULT_MAX_TURNS = 200;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The constant name FORK_DEFAULT_MAX_TURNS implies overridability ("default" suggests other values are possible), but the cap is applied unconditionally in createForkSubagent with no escape hatch — no environment variable, no config setting, no tool parameter. A user whose legitimate fork task exceeds 200 turns (e.g., bulk file edits across hundreds of files) will hit a silent termination with no way to raise the limit.

Either rename to FORK_MAX_TURNS (dropping the "DEFAULT" implication) to signal it is an absolute cap, or accept an optional max_turns from the AgentTool params and clamp it to a reasonable range, falling back to 200 when absent.

— Claude 3.5 Sonnet via Qwen Code /review

promptConfig,
{},
{} as RunConfig,
{ max_turns: FORK_DEFAULT_MAX_TURNS },

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The RunConfig for forks includes only max_turns with no max_time_minutes. A fork doing 199 turns of expensive tool calls (long shell commands, large file I/O, network requests) could still run for hours and accumulate significant API costs. The workflow orchestrator applies both a turn cap (50) and a time cap (10 minutes) for defense in depth; the fork path has only one axis of protection.

Consider adding a time cap as a second safety net:

Suggested change
{ max_turns: FORK_DEFAULT_MAX_TURNS },
{ max_turns: FORK_DEFAULT_MAX_TURNS, max_time_minutes: 30 },

— Claude 3.5 Sonnet via Qwen Code /review

@qwen-code-ci-bot

qwen-code-ci-bot commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR, @qqqys — and for the follow-up commit fixing the resume path.

Template headings diverge from the PR template ("## What" / "## Test" instead of "## What this PR does" / "## Why it's needed" / "## Reviewer Test Plan"), but all substantive content is present. Not blocking — just a heads-up for next time.

On direction: this is squarely in scope. The fork subagent runs fire-and-forget in the background with no inline UI — shipping it without a turn cap and with auto-deny on permission prompts was a clear robustness gap. Both fixes address real failure modes (unbounded token burn and silently denied tool calls), and closing #5734 confirms the user impact.

On approach: the scope is minimal — a constant, a field change, and two call-site updates. Nothing to cut. The BUBBLE_APPROVAL_MODE reuse is exactly right; it was designed for this case.

Moving on to code review. 🔍

中文说明

感谢 PR,@qqqys——以及修复 resume 路径的后续提交。

模板标题与 PR 模板 不一致(用了 "## What" / "## Test" 而不是 "## What this PR does" / "## Why it's needed" / "## Reviewer Test Plan"),但实质内容齐全,不阻塞——下次注意即可。

方向:完全在范围内。fork subagent 在后台 fire-and-forget 运行且无内联 UI——发布时缺少 turn 上限和权限提示自动拒绝是明显的健壮性缺口。两个修复都针对真实失败模式(无限 token 消耗和静默拒绝的工具调用),关闭 #5734 确认了用户影响。

方案:范围最小化——一个常量、一个字段更改和两个调用点更新。没有可砍的部分。BUBBLE_APPROVAL_MODE 的复用恰到好处,它就是为这个场景设计的。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

qwen-code-ci-bot commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal: Before reading the diff, I'd solve this with (1) a FORK_DEFAULT_MAX_TURNS constant exported from fork-subagent.ts and passed as RunConfig at both AgentHeadless.create call sites (fresh fork in agent.ts, resumed fork in background-agent-resume.ts), and (2) changing FORK_AGENT.approvalMode from 'default' to BUBBLE_APPROVAL_MODE so that permission prompts surface via the parent's Background-tasks UI.

Diff comparison: The PR matches this approach exactly. Both call sites are covered (the second commit a65273a0 added the resume path that the initial PR missed — good catch by the previous reviewer). The constant is co-located with FORK_AGENT in fork-subagent.ts, the unused RunConfig type import is cleaned up in both consumer files, and the new test cases assert on the exact positional arguments.

Findings:

  • No correctness bugs. The { max_turns: FORK_DEFAULT_MAX_TURNS } RunConfig is passed as the 5th positional arg to AgentHeadless.create(), which maps to runConfig.max_turns in agent-core.tsmaxTurnsPerMessage in InProcessBackend.ts. The turn loop in agent-core.ts checks this and terminates with MAX_TURNS when exceeded.
  • The BUBBLE_APPROVAL_MODE change is safe. isForkSubagentEnabled gates fork availability to interactive sessions only (config.isInteractive()), so bubble will always have a parent UI to surface to. Non-interactive paths never reach this code.
  • One minor note: the validation.ts module caps user-defined max_turns at 100, while FORK_DEFAULT_MAX_TURNS is 200. Not a bug — the validator runs on user-provided subagent configs, and this programmatic path bypasses it entirely. Just worth knowing in case the validator threshold changes later.

No blockers found.

Testing

These changes are purely internal to fork subagent dispatch — no TUI-visible behavior to exercise in tmux. Verification is through the unit test suite:

  • agent.test.ts — new case asserts createArgs[4] === { max_turns: FORK_DEFAULT_MAX_TURNS } and FORK_AGENT.approvalMode === BUBBLE_APPROVAL_MODE
  • background-agent-resume.test.ts — asserts the resume path also carries max_turns: FORK_DEFAULT_MAX_TURNS and adds the missing isInteractive: () => false mock

The PR author reports packages/core tests green (159 passing) and packages/cli typecheck green. CI should confirm.

中文说明

代码审查

独立方案: 读 diff 之前,我会用 (1) 在 fork-subagent.ts 导出 FORK_DEFAULT_MAX_TURNS 常量并在两个 AgentHeadless.create 调用点(agent.ts 的新鲜 fork、background-agent-resume.ts 的恢复 fork)作为 RunConfig 传入;(2) 将 FORK_AGENT.approvalMode'default' 改为 BUBBLE_APPROVAL_MODE

Diff 对比: PR 与方案完全一致。两个调用点都已覆盖(第二个 commit a65273a0 补上了初始 PR 遗漏的 resume 路径)。常量与 FORK_AGENT 同文件放置,两处消费文件中清理了未使用的 RunConfig 类型导入,新测试用例精确断言位置参数。

发现:

  • 无正确性 bug。{ max_turns: FORK_DEFAULT_MAX_TURNS } 作为第 5 个位置参数传给 AgentHeadless.create(),映射到 agent-core.tsrunConfig.max_turnsInProcessBackend.tsmaxTurnsPerMessageagent-core.ts 的 turn 循环在超限时以 MAX_TURNS 终止。
  • BUBBLE_APPROVAL_MODE 更改安全。isForkSubagentEnabled 将 fork 可用性限制在交互式会话(config.isInteractive()),因此 bubble 始终有父级 UI 可浮现。非交互路径不会到达此代码。
  • 小注:validation.ts 将用户定义的 max_turns 上限设为 100,而 FORK_DEFAULT_MAX_TURNS 是 200。不是 bug——验证器仅对用户提供的 subagent 配置运行,此程序化路径完全绕过。仅在验证器阈值将来更改时需注意。

未发现阻塞项。

测试

这些更改完全在 fork subagent 调度内部——无 TUI 可见行为可在 tmux 中测试。通过单元测试套件验证:

  • agent.test.ts — 新用例断言 createArgs[4] === { max_turns: FORK_DEFAULT_MAX_TURNS }FORK_AGENT.approvalMode === BUBBLE_APPROVAL_MODE
  • background-agent-resume.test.ts — 断言 resume 路径同样携带 max_turns: FORK_DEFAULT_MAX_TURNS 并添加了缺失的 isInteractive: () => false mock

PR 作者报告 packages/core 测试通过(159 passing),packages/cli 类型检查通过。CI 应确认。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

qwen-code-ci-bot commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

This PR does exactly what it says — two focused robustness fixes for the fork subagent, each addressing a real failure mode. The scope is minimal (a constant, a field change, two call-site updates, two test assertions), and there's no drive-by refactoring or scope creep.

My independent proposal matched the PR's approach exactly, which is a good sign — the solution is the obvious one. The second commit fixing the resume path shows the author is responsive to review feedback. wenshao has already approved, and I agree with that assessment.

The code is straightforward, the comments explain why (not what), and the tests assert on the exact contract that matters. If I had to maintain this in six months, I'd thank the author.

Approving. ✅

中文说明

此 PR 做了它所说的——两个聚焦的 fork subagent 健壮性修复,每个都针对真实失败模式。范围最小化(一个常量、一个字段更改、两个调用点更新、两个测试断言),无顺手重构或范围蔓延。

我的独立方案与 PR 方案完全一致,这是好信号——解决方案是显而易见的那个。修复 resume 路径的第二个 commit 表明作者对审查反馈积极响应。wenshao 已批准,我同意该评估。

代码直截了当,注释解释为什么(而非做了什么),测试断言了重要的精确契约。如果六个月后需要维护此代码,我会感谢作者。

批准 ✅

Qwen Code · qwen3.7-max

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Missed call site: resumed forks bypass the 200-turn cap

createResumedForkSubagent in background-agent-resume.ts:1168 still passes {} as RunConfig — no max_turns. The PR caps the fresh-launch path (agent.ts:1301), but the resume path is a separate call site that was not patched.

When a backgrounded fork is paused (user exits, process interrupted) and later resumed via BackgroundAgentResume, it runs with no turn limit — the exact scenario the turn cap was designed to prevent.

{ max_turns: FORK_DEFAULT_MAX_TURNS },

Import FORK_DEFAULT_MAX_TURNS from ../tools/agent/fork-subagent.js in background-agent-resume.ts and apply it at line 1168, matching the fix already in agent.ts:1301.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

CI failure root cause — FORK_AGENT approval-mode flip trips a missing mock method (test-only)

The 3 red Test jobs (ubuntu/macos/windows) all fail at the same place — 3 tests in one file, packages/core/src/agents/background-agent-resume.test.ts:

× resumes fork agents from transcript bootstrap instead of current parent config → expected undefined to be defined
× keeps legacy fork tasks paused when transcript bootstrap is missing      → resumeBlockedReason is undefined
× keeps fork tasks paused when bootstrap capabilities are missing          → resumeBlockedReason is undefined
 Test Files  1 failed | 438 passed | 1 skipped
       Tests  3 failed | 12952 passed | 8 skipped

(Lint, build, and the No-AK smoke test all pass — this is a unit-test failure, not a compile error.)

Root cause. This PR flips FORK_AGENT.approvalMode from 'default' to BUBBLE_APPROVAL_MODE (packages/core/src/tools/agent/fork-subagent.ts:39). The fork resume path has a pre-existing bubble gate at packages/core/src/agents/background-agent-resume.ts:703-705:

const shouldBubble = Boolean(
  target.subagentConfig?.approvalMode === BUBBLE_APPROVAL_MODE &&
    this.config.isInteractive(),   // <- only reached now that the LHS is true
);
  • Before: FORK_AGENT.approvalMode === 'default', so ... === BUBBLE_APPROVAL_MODE is false and the && short-circuitsthis.config.isInteractive() is never called on the fork path.
  • After: the LHS is true, so the && proceeds to call this.config.isInteractive().

The fork-resume tests build a hand-rolled mock Config (createService(), background-agent-resume.test.ts:82-108, cast as unknown as Config) that does not define isInteractive. So the call throws:

TypeError: this.config.isInteractive is not a function
    at resumeBackgroundAgentInternal (background-agent-resume.ts:705)

That throw is swallowed by the resume body's outer catch, which re-pauses the entry with a generic error (not a resumeBlockedReason) and returns undefined. That single throw explains all 3 assertions at once:

  • test 1 expects resumed to be defined → gets undefined (the caught throw);
  • tests 2 & 3 throw at line 705 before reaching the gates that set resumeBlockedReason → that field stays undefined.

This is a test-mock gap, not a product bug. The real Config implements isInteractive() (packages/core/src/config/config.ts:4451), so the resume path is fine in production — the change to bubble fork permission prompts is sound. Only the test's fake config is missing the method.

Fix (one line). Add isInteractive to the shared mock in createService():

       isTrustedFolder: () => true,
+      isInteractive: () => false,
       getProjectRoot: () => tempDir,

Verification. I reproduced and bisected this locally:

  1. Merge-base (pure main, no PR): the 4 fork-resume tests pass.
  2. PR head: the same 3 fail, identical to CI.
  3. Reverting only the approvalMode line (keeping the turn-cap + agent.ts changes) makes them pass → approvalMode is the sole trigger.
  4. Instrumented the catch → TypeError: this.config.isInteractive is not a function at background-agent-resume.ts:705.
  5. Adding isInteractive: () => false to the mock → all 28 tests in the file pass (Tests 28 passed).
中文版

CI 失败根因 —— FORK_AGENT 审批模式改动触发 mock 缺失方法(仅测试问题)

三个红的 Test job(ubuntu/macos/windows)都挂在同一处 —— 同一个文件 packages/core/src/agents/background-agent-resume.test.ts 里的 3 个测试

× resumes fork agents from transcript bootstrap instead of current parent config → expected undefined to be defined
× keeps legacy fork tasks paused when transcript bootstrap is missing      → resumeBlockedReason 为 undefined
× keeps fork tasks paused when bootstrap capabilities are missing          → resumeBlockedReason 为 undefined
 Test Files  1 failed | 438 passed | 1 skipped
       Tests  3 failed | 12952 passed | 8 skipped

(Lint、构建、No-AK 冒烟测试都通过 —— 这是单元测试失败,不是编译错误。)

根因。 本 PR 把 FORK_AGENT.approvalMode'default' 改成了 BUBBLE_APPROVAL_MODEpackages/core/src/tools/agent/fork-subagent.ts:39)。而 fork 的**恢复(resume)**路径在 packages/core/src/agents/background-agent-resume.ts:703-705 有一段早已存在的 bubble 判断:

const shouldBubble = Boolean(
  target.subagentConfig?.approvalMode === BUBBLE_APPROVAL_MODE &&
    this.config.isInteractive(),   // <- 现在 LHS 为 true 才会走到这里
);
  • 改动前: FORK_AGENT.approvalMode === 'default'... === BUBBLE_APPROVAL_MODEfalse&& 短路 —— fork 路径上根本不会调用 this.config.isInteractive()
  • 改动后: 左侧变成 true&& 继续求值,于是调用了 this.config.isInteractive()

而这几个 fork-resume 测试用的是手写的 mock ConfigcreateService()background-agent-resume.test.ts:82-108as unknown as Config 强转),里面没有定义 isInteractive。于是抛错:

TypeError: this.config.isInteractive is not a function
    at resumeBackgroundAgentInternal (background-agent-resume.ts:705)

这个异常被 resume 主体的外层 catch 吞掉,把条目以一个通用 error(而非 resumeBlockedReason)重新置为 paused 并返回 undefined。这一个抛错同时解释了 3 个断言失败:

  • 测试 1 期望 resumed 有值 → 得到 undefined(被 catch 的异常);
  • 测试 2、3 在第 705 行就抛错,还没走到设置 resumeBlockedReason 的分支 → 该字段保持 undefined

这是测试 mock 的缺口,不是产品 bug。 真实的 Config 是实现了 isInteractive() 的(packages/core/src/config/config.ts:4451),所以 resume 路径在生产里没问题 —— 把 fork 权限提示冒泡上去这个改动本身是合理的。只是测试里的假 config 漏了这个方法。

修复(一行)。createService() 的共享 mock 补上 isInteractive

       isTrustedFolder: () => true,
+      isInteractive: () => false,
       getProjectRoot: () => tempDir,

验证。 我在本地复现并做了二分定位:

  1. merge-base(纯 main,无本 PR):4 个 fork-resume 测试通过
  2. PR head:同样那 3 个失败,与 CI 一致。
  3. 只把 approvalMode 那一行改回来(保留 turn-cap 和 agent.ts 的改动),测试就全过 → 唯一触发点就是 approvalMode
  4. 给 catch 加探针 → 抓到 TypeError: this.config.isInteractive is not a function,位置在 background-agent-resume.ts:705
  5. 给 mock 加上 isInteractive: () => false该文件 28 个测试全过Tests 28 passed)。

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No additional findings from this pass.

I re-checked the fork launch change and focused on the surrounding resume path. The fresh fork path now carries max_turns: 200 and the new agent.test.ts case passes locally. I also reproduced the existing background-agent-resume.test.ts failures, and the underlying resume-path/test-mock issues are already covered by the existing PR discussion, so I am not adding duplicate inline comments here.

— GPT-5 Codex via Qwen Code /review

@wenshao

wenshao commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@qwen-code-ci-bot qwen-code-ci-bot added category/core Core engine and logic scope/session-management Session state and persistence type/bug Something isn't working as expected labels Jun 23, 2026

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found. LGTM! ✅

Both fixes are well-scoped and correctly implemented — the turn cap is properly wired through both the fresh-launch and resume paths, and the bubble approval mode change is gated on isInteractive() to avoid headless regressions. Tests cover the structural contracts.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

✅ E2E verification — fork turn cap & permission bubbling

Built the CLI from this PR's HEAD (a65273a0) and drove the real interactive TUI via tmux against a deterministic mock OpenAI server (so a detached fork reliably emits tool calls). Each fix was A/B-tested by swapping only the built dist between this PR's behavior and the pre-PR behavior, holding the other change constant — so every difference below is attributable to exactly one line of the diff.

Fix 2 — bubble fork permission prompts (FORK_AGENT.approvalMode)

Fork directed to call a permission-gated tool (write_file); max_turns: 200 held constant in both arms:

pre-PR approvalMode: 'default' this PR approvalMode: 'bubble'
Gated tool call silently auto-denied prompt surfaces in the parent's Background-tasks UI
Fork transcript 200 × Tool "write_file" requires permission, but background agents cannot prompt for confirmation. The tool call was denied. 1 turn, then pauses awaiting approval — 0 denials
Outcome file never written; the fork fails having accomplished nothing (it cannot even run its mandated "commit before reporting") approve in the UI → tool executes → fork_marker.txt written ✔

The bubbled prompt, exactly as it appears in the parent session ( opens the background-tasks panel → Enter for detail):

fork › create the marker file please
1 tool call

Progress
> WriteFile(Writing to fork_marker.txt)

 Background agent needs approval
 1 FORK_E2E_MARKER
 Apply this change?
 › 1. Yes, allow once
   2. No

Footer pill in default view: 1 local agent ⚠ needs approval. Selecting Yes lets the fork finish the write — confirming permission prompts now reach the user instead of being silently swallowed.

Fix 1 — cap fork turns (max_turns: FORK_DEFAULT_MAX_TURNS = 200)

Non-converging fork (model keeps requesting an auto-approved tool); approvalMode: 'bubble' held constant in both arms:

pre-PR {} as RunConfig this PR { max_turns: 200 }
Behavior unbounded — 686 turns in ~11 s and still running when forcibly killed terminates at exactly 200 turns
Termination record none (would burn tokens indefinitely) status: "failed", lastError: "Agent terminated with mode: MAX_TURNS"

Deterministic layer

  • npm run build ✅ (0 errors)
  • fork unit tests ✅ — agent.test.ts (111) + background-agent-resume.test.ts (28), 139 passing, including the new max_turns: 200 and approvalMode === BUBBLE_APPROVAL_MODE assertions.

Scope notes

  • The launch path (agent.ts) and approvalMode (fork-subagent.ts) were exercised end-to-end. The resume path cap (background-agent-resume.ts) is the identical one-line change and is covered by the unit suite, not separately driven in the live harness.
  • A mock model was used only to make the fork deterministically emit tool calls; all code under test is the real built artifact, unmodified except for the A/B dist swaps described above.

Verdict: both fixes behave exactly as described — the gated-tool prompt now bubbles to the user, and a runaway fork is bounded at 200 turns. LGTM for merge. ✅

🇨🇳 中文版本

✅ 端到端验证 —— fork 轮次上限 & 权限弹窗冒泡

从本 PR 的 HEAD(a65273a0)构建出真实 CLI,并通过 tmux 驱动真实交互式 TUI,后端接一个确定性的 mock OpenAI server(让游离的 fork 稳定地发起工具调用)。每个修复都通过仅替换构建产物 dist 在「本 PR 行为」与「PR 之前行为」之间做 A/B 对比,且固定另一处改动不变 —— 因此下面每一处差异都可精确归因到 diff 中的某一行。

修复 2 —— fork 权限弹窗冒泡(FORK_AGENT.approvalMode

让 fork 调用一个需要授权的工具(write_file),两组都固定 max_turns: 200

PR 之前 approvalMode: 'default' 本 PR approvalMode: 'bubble'
受限工具调用 被静默自动拒绝 提示冒泡到父会话的 Background-tasks UI
fork transcript 200 次 Tool "write_file" requires permission, but background agents cannot prompt for confirmation. The tool call was denied. 仅 1 轮,随后暂停等待授权 —— 0 次拒绝
结果 文件从未写入;fork 一事无成地失败(连它被强制要求的「报告前先提交」都做不了) 在 UI 中批准 → 工具执行 → 成功写入 fork_marker.txt

冒泡出的授权提示在父会话中的真实样子( 打开后台任务面板 → Enter 查看详情):

fork › create the marker file please
1 tool call

Progress
> WriteFile(Writing to fork_marker.txt)

 Background agent needs approval
 1 FORK_E2E_MARKER
 Apply this change?
 › 1. Yes, allow once
   2. No

默认视图底部提示:1 local agent ⚠ needs approval。选择 Yes 后 fork 即可完成写入 —— 证明权限提示现在能送达用户,而不再被静默吞掉。

修复 1 —— fork 轮次上限(max_turns: FORK_DEFAULT_MAX_TURNS = 200

构造一个无法收敛的 fork(模型持续请求一个自动放行的工具),两组都固定 approvalMode: 'bubble'

PR 之前 {} as RunConfig 本 PR { max_turns: 200 }
行为 无上限 —— 约 11 秒内跑了 686 轮,被强制 kill 时仍在运行 恰好在第 200 轮终止
终止记录 无(会无限消耗 token) status: "failed", lastError: "Agent terminated with mode: MAX_TURNS"

确定性层

  • npm run build ✅(0 error)
  • fork 单测 ✅ —— agent.test.ts(111)+ background-agent-resume.test.ts(28),139 个全部通过,含新增的 max_turns: 200approvalMode === BUBBLE_APPROVAL_MODE 断言。

范围说明

  • 启动路径(agent.ts)与 approvalModefork-subagent.ts)已端到端验证。**恢复(resume)**路径的上限(background-agent-resume.ts)是完全相同的一行改动,由单测覆盖,未在实机 harness 中单独驱动。
  • mock 模型仅用于让 fork 确定性地发出工具调用;被测代码全部是真实构建产物,除上述 A/B dist 替换外未作任何改动。

结论:两处修复的行为与描述完全一致 —— 受限工具的提示现在会冒泡给用户,失控的 fork 被限制在 200 轮。建议合并。✅

@qqqys
qqqys merged commit 65fe505 into QwenLM:main Jun 23, 2026
30 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

category/core Core engine and logic scope/session-management Session state and persistence type/bug Something isn't working as expected

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fork subagent hardening: unbounded turn count + permission-gated tool calls silently auto-denied

4 participants