diff --git a/packages/core/src/agents/background-agent-resume.test.ts b/packages/core/src/agents/background-agent-resume.test.ts index af20fe101e..e165f00619 100644 --- a/packages/core/src/agents/background-agent-resume.test.ts +++ b/packages/core/src/agents/background-agent-resume.test.ts @@ -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' }], }); diff --git a/packages/core/src/agents/background-agent-resume.ts b/packages/core/src/agents/background-agent-resume.ts index f2ee6d0e98..7ed3630192 100644 --- a/packages/core/src/agents/background-agent-resume.ts +++ b/packages/core/src/agents/background-agent-resume.ts @@ -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, ); diff --git a/packages/core/src/tools/agent/agent.test.ts b/packages/core/src/tools/agent/agent.test.ts index 0135225f99..5915294938 100644 --- a/packages/core/src/tools/agent/agent.test.ts +++ b/packages/core/src/tools/agent/agent.test.ts @@ -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: '', + }; + 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. diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index deb4f216c9..f2257fb91c 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -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 { agentConfig, promptConfig, {}, - {} as RunConfig, + { max_turns: FORK_DEFAULT_MAX_TURNS }, toolConfig, eventEmitter, ); diff --git a/packages/core/src/tools/agent/fork-subagent.ts b/packages/core/src/tools/agent/fork-subagent.ts index 7854846eef..1b2e1e4f9c 100644 --- a/packages/core/src/tools/agent/fork-subagent.ts +++ b/packages/core/src/tools/agent/fork-subagent.ts @@ -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