fix(agent): cap fork turns and bubble fork permission prompts (#5737)
Some checks are pending
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Lint (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Blocked by required conditions
Qwen Code CI / Integration Tests (No-AK Smoke) (push) Blocked by required conditions
Qwen Code CI / Integration Tests (CLI, No Sandbox) (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run

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

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 #5734.

Co-authored-by: Qwen-Coder <noreply@qwen.ai>

* fix(agent): cap resumed fork turns

---------

Co-authored-by: Qwen-Coder <noreply@qwen.ai>
This commit is contained in:
qqqys 2026-06-23 19:18:01 +08:00 committed by GitHub
parent c9b5c99e44
commit 65fe505910
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 52 additions and 9 deletions

View file

@ -21,6 +21,7 @@ import { AgentTerminateMode } from './runtime/agent-types.js';
import { AgentEventEmitter } from './runtime/agent-events.js';
import { AgentHeadless } from './runtime/agent-headless.js';
import {
FORK_DEFAULT_MAX_TURNS,
FORK_SUBAGENT_TYPE,
buildChildMessage,
} from '../tools/agent/fork-subagent.js';
@ -96,6 +97,7 @@ describe('BackgroundAgentResumeService', () => {
getMaxSessionTurns: () => -1,
getMaxToolCalls: () => -1,
isTrustedFolder: () => true,
isInteractive: () => false,
getProjectRoot: () => tempDir,
getCliVersion: () => 'test-version',
getGeminiClient: () => undefined,
@ -1351,6 +1353,9 @@ describe('BackgroundAgentResumeService', () => {
{ role: 'model', parts: [{ text: 'Working silently' }] },
],
});
expect(createArgs?.[4]).toEqual({
max_turns: FORK_DEFAULT_MAX_TURNS,
});
expect(createArgs?.[5]).toEqual({
tools: [{ name: 'Bash' }, { name: 'Read' }],
});

View file

@ -36,6 +36,7 @@ import { createApprovalModeOverride } from '../tools/agent/agent.js';
import type { ApprovalMode } from '../config/config.js';
import {
FORK_AGENT,
FORK_DEFAULT_MAX_TURNS,
FORK_SUBAGENT_TYPE,
runInForkContext,
} from '../tools/agent/fork-subagent.js';
@ -48,11 +49,7 @@ import type { SubagentConfig } from '../subagents/types.js';
import { BUBBLE_APPROVAL_MODE } from '../subagents/types.js';
import { EXCLUDED_TOOLS_FOR_SUBAGENTS } from './runtime/agent-core.js';
import { ToolNames } from '../tools/tool-names.js';
import type {
PromptConfig,
RunConfig,
ToolConfig,
} from './runtime/agent-types.js';
import type { PromptConfig, ToolConfig } from './runtime/agent-types.js';
import type {
AgentBootstrapRecordPayload,
NotificationRecordPayload,
@ -1168,7 +1165,7 @@ export class BackgroundAgentResumeService {
agentConfig,
promptConfig,
{},
{} as RunConfig,
{ max_turns: FORK_DEFAULT_MAX_TURNS },
toolConfig,
eventEmitter,
);

View file

@ -17,6 +17,8 @@ import { ToolNames } from '../tool-names.js';
import { type Config, ApprovalMode } from '../../config/config.js';
import { SubagentManager } from '../../subagents/subagent-manager.js';
import type { SubagentConfig } from '../../subagents/types.js';
import { BUBBLE_APPROVAL_MODE } from '../../subagents/types.js';
import { FORK_AGENT, FORK_DEFAULT_MAX_TURNS } from './fork-subagent.js';
import { AgentTerminateMode } from '../../agents/runtime/agent-types.js';
import {
AgentHeadless,
@ -1313,6 +1315,37 @@ describe('AgentTool', () => {
expect(AgentHeadless.create).toHaveBeenCalledTimes(1);
});
it('caps fork turns and uses bubble approval mode', async () => {
const mockLoadedSubagent: SubagentConfig = {
name: 'general-purpose',
description: 'General-purpose agent',
systemPrompt: 'You are a general-purpose agent.',
level: 'builtin',
filePath: '<builtin:general-purpose>',
};
vi.mocked(mockSubagentManager.loadSubagent).mockResolvedValue(
mockLoadedSubagent,
);
const invocation = (
agentTool as AgentToolWithProtectedMethods
).createInvocation({
description: 'some task',
prompt: 'do the thing',
subagent_type: 'fork',
});
await invocation.execute();
expect(AgentHeadless.create).toHaveBeenCalledTimes(1);
const createArgs = vi.mocked(AgentHeadless.create).mock.calls[0];
// RunConfig (5th positional) carries the detached-fork turn cap so a
// fire-and-forget fork can't loop unbounded.
expect(createArgs[4]).toEqual({ max_turns: FORK_DEFAULT_MAX_TURNS });
// The fork agent uses `bubble` approval so its permission prompts surface
// to the parent's Background-tasks UI instead of being auto-denied.
expect(FORK_AGENT.approvalMode).toBe(BUBBLE_APPROVAL_MODE);
});
it('omitting subagent_type uses general-purpose, not fork', async () => {
// Restored contract: omission resolves to the awaitable general-purpose
// subagent (inline result), never a fork — even in interactive mode.

View file

@ -26,7 +26,6 @@ import { BUBBLE_APPROVAL_MODE } from '../../subagents/types.js';
import { AgentTerminateMode } from '../../agents/runtime/agent-types.js';
import type {
PromptConfig,
RunConfig,
ToolConfig,
} from '../../agents/runtime/agent-types.js';
import {
@ -37,6 +36,7 @@ import type { AgentExternalInput } from '../../agents/runtime/agent-types.js';
import type { Content, FunctionDeclaration } from '@google/genai';
import {
FORK_AGENT,
FORK_DEFAULT_MAX_TURNS,
FORK_SUBAGENT_TYPE,
FORK_PLACEHOLDER_RESULT,
buildForkedMessages,
@ -1298,7 +1298,7 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
agentConfig,
promptConfig,
{},
{} as RunConfig,
{ max_turns: FORK_DEFAULT_MAX_TURNS },
toolConfig,
eventEmitter,
);

View file

@ -2,6 +2,7 @@ import { AsyncLocalStorage } from 'node:async_hooks';
import type { Content } from '@google/genai';
import type { Config } from '../../config/config.js';
import type { SubagentConfig } from '../../subagents/types.js';
import { BUBBLE_APPROVAL_MODE } from '../../subagents/types.js';
export const FORK_SUBAGENT_TYPE = 'fork';
@ -33,10 +34,17 @@ export const FORK_AGENT = {
tools: ['*'],
systemPrompt:
'You are a forked worker process. Follow the directive in the conversation history. Execute tasks directly using available tools. Do not spawn sub-agents.',
approvalMode: 'default',
// `bubble` surfaces this fork's permission prompts to the parent's Background-
// tasks UI; a detached fork has no inline UI, so 'default' would auto-deny them.
approvalMode: BUBBLE_APPROVAL_MODE,
level: 'session' as const,
} satisfies SubagentConfig;
// 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;
// Recursive-fork guard. A fork child keeps the `agent` tool in its declarations
// for byte-identical cache parity with the parent, so tool-availability
// stripping is no longer an option. Instead, mark the async frame as "inside a