mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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
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:
parent
c9b5c99e44
commit
65fe505910
5 changed files with 52 additions and 9 deletions
|
|
@ -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' }],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue