diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index b4b73ee70..97627b7e6 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -110,29 +110,9 @@ export interface CreateChatOptions { /** * Optional conversation history from a parent session. When provided, * this history is prepended to the chat so the agent has prior - * conversational context (e.g., from the main session that spawned it). + * conversational context (e.g., from AgentInteractive.start()). */ extraHistory?: Content[]; - /** - * When provided, replaces the auto-built generationConfig - * (systemInstruction, temperature, etc.) with this exact config. - * Used by fork subagents to share the parent conversation's cache - * prefix for DashScope prompt caching. - */ - generationConfigOverride?: GenerateContentConfig & { - systemInstruction?: string | Content; - }; - /** - * When true, skip injecting the env bootstrap messages from - * `getInitialChatHistory()`. Set by fork subagents because their - * `extraHistory` is the full parent history that already contains - * those env messages — re-injecting would duplicate them. - * - * Other callers (e.g. arena interactive agents) pass an - * env-stripped history and DO need fresh env init for their own - * working directory, so they must leave this unset. - */ - skipEnvHistory?: boolean; } /** @@ -243,21 +223,31 @@ export class AgentCore { context: ContextState, options?: CreateChatOptions, ): Promise { - if (!this.promptConfig.systemPrompt && !this.promptConfig.initialMessages) { + if ( + !this.promptConfig.systemPrompt && + !this.promptConfig.renderedSystemPrompt && + !this.promptConfig.initialMessages + ) { throw new Error( - 'PromptConfig must have either `systemPrompt` or `initialMessages` defined.', + 'PromptConfig must have `systemPrompt`, `renderedSystemPrompt`, or `initialMessages` defined.', ); } - if (this.promptConfig.systemPrompt && this.promptConfig.initialMessages) { + if ( + this.promptConfig.systemPrompt && + this.promptConfig.renderedSystemPrompt + ) { throw new Error( - 'PromptConfig cannot have both `systemPrompt` and `initialMessages` defined.', + 'PromptConfig cannot have both `systemPrompt` and `renderedSystemPrompt` defined.', ); } - // Skip env bootstrap when the caller (fork) explicitly says its - // extraHistory already contains those messages. Other callers that - // provide an env-stripped history (e.g. arena) still get fresh env init. - const envHistory = options?.skipEnvHistory + // When initialMessages is set, the caller owns the full prior history + // (including any env bootstrap it wants). Fork relies on this to inherit + // the parent conversation verbatim without duplicating env messages. + const hasInitialMessages = + !!this.promptConfig.initialMessages && + this.promptConfig.initialMessages.length > 0; + const envHistory = hasInitialMessages ? [] : await getInitialChatHistory(this.runtimeContext); @@ -267,24 +257,19 @@ export class AgentCore { ...(this.promptConfig.initialMessages ?? []), ]; - // If an override is provided (fork path), use it directly for cache - // sharing. Otherwise, build the config from this agent's promptConfig. - // Note: buildChatSystemPrompt is called OUTSIDE the try/catch so template - // errors propagate to the caller (not swallowed by reportError). - let generationConfig: GenerateContentConfig & { + // Build generationConfig. For fork subagents, `renderedSystemPrompt` + // carries the parent's exact rendered systemInstruction so the fork + // shares a byte-identical cache prefix. Otherwise, template + // `systemPrompt` via buildChatSystemPrompt (which may throw — kept + // outside the try/catch so template errors surface to the caller). + const generationConfig: GenerateContentConfig & { systemInstruction?: string | Content; - }; - - if (options?.generationConfigOverride) { - generationConfig = options.generationConfigOverride; - } else { - const systemInstruction = this.promptConfig.systemPrompt - ? this.buildChatSystemPrompt(context, options) - : undefined; - generationConfig = { - temperature: this.modelConfig.temp, - topP: this.modelConfig.top_p, - }; + } = {}; + if (this.promptConfig.renderedSystemPrompt !== undefined) { + generationConfig.systemInstruction = + this.promptConfig.renderedSystemPrompt; + } else if (this.promptConfig.systemPrompt) { + const systemInstruction = this.buildChatSystemPrompt(context, options); if (systemInstruction) { generationConfig.systemInstruction = systemInstruction; } @@ -330,7 +315,10 @@ export class AgentCore { (t): t is FunctionDeclaration => typeof t !== 'string', ); - if (hasWildcard || asStrings.length === 0) { + if ( + hasWildcard || + (asStrings.length === 0 && onlyInlineDecls.length === 0) + ) { toolsList.push( ...toolRegistry .getFunctionDeclarations() diff --git a/packages/core/src/agents/runtime/agent-headless.test.ts b/packages/core/src/agents/runtime/agent-headless.test.ts index 01ff1b040..89760ee0b 100644 --- a/packages/core/src/agents/runtime/agent-headless.test.ts +++ b/packages/core/src/agents/runtime/agent-headless.test.ts @@ -231,8 +231,6 @@ describe('subagent.ts', () => { const defaultModelConfig: ModelConfig = { model: 'qwen3-coder-plus', - temp: 0.5, // Specific temp to test override - top_p: 1, }; const defaultRunConfig: RunConfig = { @@ -439,8 +437,6 @@ describe('subagent.ts', () => { // Check Generation Config const generationConfig = getGenerationConfigFromMock(); - // Check temperature override - expect(generationConfig.temperature).toBe(defaultModelConfig.temp); expect(generationConfig.systemInstruction).toContain( 'Hello Agent, your task is Testing.', ); @@ -556,15 +552,20 @@ describe('subagent.ts', () => { expect(sysPrompt).not.toContain('---'); }); - it('should use initialMessages instead of systemPrompt if provided', async () => { + it('should replace env history with initialMessages when both initialMessages and systemPrompt are set', async () => { const { config } = await createMockConfig(); vi.mocked(GeminiChat).mockClear(); const initialMessages: Content[] = [ - { role: 'user', parts: [{ text: 'Hi' }] }, + { role: 'user', parts: [{ text: 'prior user turn' }] }, + { role: 'model', parts: [{ text: 'prior model turn' }] }, ]; - const promptConfig: PromptConfig = { initialMessages }; + const promptConfig: PromptConfig = { + systemPrompt: 'System ${name}.', + initialMessages, + }; const context = new ContextState(); + context.set('name', 'Agent'); // Model stops immediately mockSendMessageStream.mockImplementation(createMockStream(['stop'])); @@ -583,15 +584,44 @@ describe('subagent.ts', () => { const generationConfig = getGenerationConfigFromMock(); const history = callArgs[2]; - expect(generationConfig.systemInstruction).toBeUndefined(); - expect(history).toEqual([ - { role: 'user', parts: [{ text: 'Env Context' }] }, - { - role: 'model', - parts: [{ text: 'Got it. Thanks for the context!' }], - }, - ...initialMessages, - ]); + // systemPrompt is templated normally. + expect(generationConfig.systemInstruction).toContain('System Agent.'); + expect(generationConfig.systemInstruction).toContain( + 'Important Rules:', + ); + // Env bootstrap is skipped; history is exactly initialMessages. + expect(history).toEqual(initialMessages); + }); + + it('should use renderedSystemPrompt verbatim and bypass templating', async () => { + const { config } = await createMockConfig(); + vi.mocked(GeminiChat).mockClear(); + + const rendered = 'Verbatim parent system prompt ${name}'; + const promptConfig: PromptConfig = { + renderedSystemPrompt: rendered, + initialMessages: [ + { role: 'user', parts: [{ text: 'hi' }] }, + { role: 'model', parts: [{ text: 'ok' }] }, + ], + }; + const context = new ContextState(); + + mockSendMessageStream.mockImplementation(createMockStream(['stop'])); + + const scope = await AgentHeadless.create( + 'test-agent', + config, + promptConfig, + defaultModelConfig, + defaultRunConfig, + ); + + await scope.execute(context); + + const generationConfig = getGenerationConfigFromMock(); + // No ${name} substitution and no non-interactive rules appended. + expect(generationConfig.systemInstruction).toBe(rendered); }); it('should throw an error if template variables are missing', async () => { @@ -618,11 +648,11 @@ describe('subagent.ts', () => { expect(scope.getTerminateMode()).toBe(AgentTerminateMode.ERROR); }); - it('should validate that systemPrompt and initialMessages are mutually exclusive', async () => { + it('should validate that systemPrompt and renderedSystemPrompt are mutually exclusive', async () => { const { config } = await createMockConfig(); const promptConfig: PromptConfig = { systemPrompt: 'System', - initialMessages: [{ role: 'user', parts: [{ text: 'Hi' }] }], + renderedSystemPrompt: 'Rendered', }; const context = new ContextState(); @@ -635,7 +665,7 @@ describe('subagent.ts', () => { ); await expect(agent.execute(context)).rejects.toThrow( - 'PromptConfig cannot have both `systemPrompt` and `initialMessages` defined.', + 'PromptConfig cannot have both `systemPrompt` and `renderedSystemPrompt` defined.', ); expect(agent.getTerminateMode()).toBe(AgentTerminateMode.ERROR); }); diff --git a/packages/core/src/agents/runtime/agent-headless.ts b/packages/core/src/agents/runtime/agent-headless.ts index 55fb7ba24..ac02f80df 100644 --- a/packages/core/src/agents/runtime/agent-headless.ts +++ b/packages/core/src/agents/runtime/agent-headless.ts @@ -192,21 +192,8 @@ export class AgentHeadless { async execute( context: ContextState, externalSignal?: AbortSignal, - options?: { - extraHistory?: Array; - /** Override generationConfig for cache sharing (fork subagent). */ - generationConfigOverride?: import('@google/genai').GenerateContentConfig; - /** Override tool declarations for cache sharing (fork subagent). */ - toolsOverride?: Array; - /** Skip env bootstrap injection (fork already inherits parent env). */ - skipEnvHistory?: boolean; - }, ): Promise { - const chat = await this.core.createChat(context, { - extraHistory: options?.extraHistory, - generationConfigOverride: options?.generationConfigOverride, - skipEnvHistory: options?.skipEnvHistory, - }); + const chat = await this.core.createChat(context); if (!chat) { this.terminateMode = AgentTerminateMode.ERROR; @@ -225,7 +212,7 @@ export class AgentHeadless { abortController.abort(); } - const toolsList = options?.toolsOverride ?? this.core.prepareTools(); + const toolsList = this.core.prepareTools(); const initialTaskText = String( (context.get('task_prompt') as string) ?? 'Get Started!', diff --git a/packages/core/src/agents/runtime/agent-types.ts b/packages/core/src/agents/runtime/agent-types.ts index 1f6f15343..e6c4e4e40 100644 --- a/packages/core/src/agents/runtime/agent-types.ts +++ b/packages/core/src/agents/runtime/agent-types.ts @@ -21,13 +21,23 @@ import type { Content, FunctionDeclaration } from '@google/genai'; export interface PromptConfig { /** * A single system prompt string that defines the agent's persona and instructions. - * Note: You should use either `systemPrompt` or `initialMessages`, but not both. + * Templated via ${var} substitution, optionally suffixed with non-interactive + * rules and user memory. Mutually exclusive with `renderedSystemPrompt`. */ systemPrompt?: string; /** - * An array of user/model content pairs to seed the chat history for few-shot prompting. - * Note: You should use either `systemPrompt` or `initialMessages`, but not both. + * A pre-rendered system instruction consumed verbatim — no templating, no + * non-interactive suffix, no user-memory injection. Used by fork subagents + * to share the parent conversation's exact cache prefix. Mutually exclusive + * with `systemPrompt`. + */ + renderedSystemPrompt?: string | Content; + + /** + * Seed chat history. When set, fully replaces the default env bootstrap + * (the caller owns the full prior context, e.g. fork inheriting parent + * history). Can coexist with `systemPrompt` / `renderedSystemPrompt`. */ initialMessages?: Content[]; } @@ -42,10 +52,6 @@ export interface ModelConfig { * TODO: In the future, this needs to support 'auto' or some other string to support routing use cases. */ model?: string; - /** The temperature for the model's sampling process. */ - temp?: number; - /** The top-p value for nucleus sampling. */ - top_p?: number; } /** diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 40ebe6277..23235f4ab 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -58,7 +58,7 @@ import { canUseRipgrep } from '../utils/ripgrepUtils.js'; import { RipGrepTool } from '../tools/ripGrep.js'; import { ShellTool } from '../tools/shell.js'; import { SkillTool } from '../tools/skill.js'; -import { AgentTool } from '../tools/agent.js'; +import { AgentTool } from '../tools/agent/agent.js'; import { TodoWriteTool } from '../tools/todoWrite.js'; import { ToolRegistry } from '../tools/tool-registry.js'; import { WebFetchTool } from '../tools/web-fetch.js'; diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index b8a23b0d3..0a7cb7850 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -47,7 +47,7 @@ import { import { LoopDetectionService } from '../services/loopDetectionService.js'; // Tools -import { AgentTool } from '../tools/agent.js'; +import { AgentTool } from '../tools/agent/agent.js'; import type { RelevantAutoMemoryPromptResult } from '../memory/manager.js'; // Telemetry diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 51593bb9e..248dfedff 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -90,7 +90,7 @@ export * from './tools/ripGrep.js'; export * from './tools/sdk-control-client-transport.js'; export * from './tools/shell.js'; export * from './tools/skill.js'; -export * from './tools/agent.js'; +export * from './tools/agent/agent.js'; export * from './tools/todoWrite.js'; export * from './tools/tool-error.js'; export * from './tools/tool-registry.js'; diff --git a/packages/core/src/memory/extractionAgentPlanner.ts b/packages/core/src/memory/extractionAgentPlanner.ts index 058682e06..6a8912798 100644 --- a/packages/core/src/memory/extractionAgentPlanner.ts +++ b/packages/core/src/memory/extractionAgentPlanner.ts @@ -6,7 +6,7 @@ import type { Config } from '../config/config.js'; import { runForkedAgent, getCacheSafeParams } from '../utils/forkedAgent.js'; -import { buildFunctionResponseParts } from '../agents/runtime/forkSubagent.js'; +import { buildFunctionResponseParts } from '../tools/agent/fork-subagent.js'; import type { Content } from '@google/genai'; import type { PermissionManager } from '../permissions/permission-manager.js'; import type { @@ -324,7 +324,6 @@ export async function runAutoMemoryExtractionByAgent( ToolNames.EDIT, ], extraHistory, - skipEnvHistory: true, }); if (result.status !== 'completed') { diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 1bfd09ced..9cef68ce4 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -20,7 +20,7 @@ export { ToolCallDecision }; import type { OutputFormat } from '../output/types.js'; import { ToolNames } from '../tools/tool-names.js'; import type { SkillTool } from '../tools/skill.js'; -import type { AgentTool } from '../tools/agent.js'; +import type { AgentTool } from '../tools/agent/agent.js'; export interface BaseTelemetryEvent { 'event.name': string; diff --git a/packages/core/src/tools/agent.test.ts b/packages/core/src/tools/agent/agent.test.ts similarity index 91% rename from packages/core/src/tools/agent.test.ts rename to packages/core/src/tools/agent/agent.test.ts index 1a203cebf..2a1b603f6 100644 --- a/packages/core/src/tools/agent.test.ts +++ b/packages/core/src/tools/agent/agent.test.ts @@ -11,26 +11,26 @@ import { resolveSubagentApprovalMode, } from './agent.js'; import type { PartListUnion } from '@google/genai'; -import type { ToolResultDisplay, AgentResultDisplay } from './tools.js'; -import { ToolConfirmationOutcome } from './tools.js'; -import { type Config, ApprovalMode } from '../config/config.js'; -import { SubagentManager } from '../subagents/subagent-manager.js'; -import type { SubagentConfig } from '../subagents/types.js'; -import { AgentTerminateMode } from '../agents/runtime/agent-types.js'; +import type { ToolResultDisplay, AgentResultDisplay } from '../tools.js'; +import { ToolConfirmationOutcome } from '../tools.js'; +import { type Config, ApprovalMode } from '../../config/config.js'; +import { SubagentManager } from '../../subagents/subagent-manager.js'; +import type { SubagentConfig } from '../../subagents/types.js'; +import { AgentTerminateMode } from '../../agents/runtime/agent-types.js'; import { - type AgentHeadless, + AgentHeadless, ContextState, -} from '../agents/runtime/agent-headless.js'; -import { AgentEventType } from '../agents/runtime/agent-events.js'; +} from '../../agents/runtime/agent-headless.js'; +import { AgentEventType } from '../../agents/runtime/agent-events.js'; import type { AgentToolCallEvent, AgentToolResultEvent, AgentApprovalRequestEvent, AgentEventEmitter, -} from '../agents/runtime/agent-events.js'; -import { partToString } from '../utils/partUtils.js'; -import type { HookSystem } from '../hooks/hookSystem.js'; -import { PermissionMode } from '../hooks/types.js'; +} from '../../agents/runtime/agent-events.js'; +import { partToString } from '../../utils/partUtils.js'; +import type { HookSystem } from '../../hooks/hookSystem.js'; +import { PermissionMode } from '../../hooks/types.js'; // Type for accessing protected methods in tests type AgentToolInvocation = { @@ -50,8 +50,8 @@ type AgentToolWithProtectedMethods = AgentTool & { }; // Mock dependencies -vi.mock('../subagents/subagent-manager.js'); -vi.mock('../agents/runtime/agent-headless.js'); +vi.mock('../../subagents/subagent-manager.js'); +vi.mock('../../agents/runtime/agent-headless.js'); const MockedSubagentManager = vi.mocked(SubagentManager); const MockedContextState = vi.mocked(ContextState); @@ -404,12 +404,6 @@ describe('AgentTool', () => { expect(mockAgent.execute).toHaveBeenCalledWith( mockContextState, undefined, // signal parameter (undefined when not provided) - { - extraHistory: undefined, - generationConfigOverride: undefined, - toolsOverride: undefined, - skipEnvHistory: false, - }, ); const llmText = partToString(result.llmContent); @@ -562,6 +556,99 @@ describe('AgentTool', () => { }); }); + describe('Fork dispatch (subagent_type omitted)', () => { + let mockAgent: AgentHeadless; + let mockContextState: ContextState; + + beforeEach(() => { + mockAgent = { + execute: vi.fn().mockResolvedValue(undefined), + getFinalText: vi.fn().mockReturnValue(''), + getExecutionSummary: vi.fn().mockReturnValue({ + rounds: 0, + totalDurationMs: 0, + totalToolCalls: 0, + successfulToolCalls: 0, + failedToolCalls: 0, + successRate: 0, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + estimatedCost: 0, + toolUsage: [], + }), + getStatistics: vi.fn().mockReturnValue({ + rounds: 0, + totalDurationMs: 0, + totalToolCalls: 0, + successfulToolCalls: 0, + failedToolCalls: 0, + }), + getTerminateMode: vi.fn().mockReturnValue(AgentTerminateMode.GOAL), + } as unknown as AgentHeadless; + + mockContextState = { + set: vi.fn(), + } as unknown as ContextState; + + MockedContextState.mockImplementation(() => mockContextState); + + // Parent conversation history: empty (first-turn fork — falls back to + // the fork agent's own systemPrompt + wildcard tools because no + // cache params have been captured yet). + vi.mocked(config.getGeminiClient).mockReturnValue({ + getHistory: vi.fn().mockReturnValue([]), + getChat: vi.fn().mockReturnValue({ + getGenerationConfig: vi.fn().mockReturnValue({}), + }), + } as unknown as ReturnType); + + vi.mocked(AgentHeadless.create).mockResolvedValue(mockAgent); + }); + + it('should call AgentHeadless.create directly and execute without options', async () => { + const params: AgentParams = { + description: 'fork task', + prompt: 'do the thing', + }; + + const invocation = ( + agentTool as AgentToolWithProtectedMethods + ).createInvocation(params); + const result = await invocation.execute(); + + // Fork path: AgentHeadless.create invoked directly, bypassing + // SubagentManager.createAgentHeadless. + expect(AgentHeadless.create).toHaveBeenCalledTimes(1); + expect(mockSubagentManager.createAgentHeadless).not.toHaveBeenCalled(); + + const createArgs = vi.mocked(AgentHeadless.create).mock.calls[0]; + expect(createArgs[0]).toBe('fork'); // name + // First-turn fork (no cache params): systemPrompt path, no + // renderedSystemPrompt. initialMessages is undefined (empty history). + const promptConfig = createArgs[2]; + expect(promptConfig.renderedSystemPrompt).toBeUndefined(); + expect(promptConfig.systemPrompt).toBeDefined(); + // ToolConfig inherits wildcard for first-turn fallback. + const toolConfig = createArgs[5]; + expect(toolConfig?.tools).toEqual(['*']); + + // Fork returns the placeholder synchronously. + const llmText = partToString(result.llmContent); + expect(llmText).toBe('Fork started — processing in background'); + + // Drain the background executeSubagent() promise so its assertions + // become visible before the test ends. + await vi.runAllTimersAsync(); + + // execute() called without a third options argument. + expect(mockAgent.execute).toHaveBeenCalledWith( + mockContextState, + undefined, + ); + }); + }); + describe('SubagentStart hook integration', () => { let mockAgent: AgentHeadless; let mockContextState: ContextState; diff --git a/packages/core/src/tools/agent.ts b/packages/core/src/tools/agent/agent.ts similarity index 65% rename from packages/core/src/tools/agent.ts rename to packages/core/src/tools/agent/agent.ts index b7f2f045f..2ff98baf3 100644 --- a/packages/core/src/tools/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -4,40 +4,56 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; -import { ToolNames, ToolDisplayNames } from './tool-names.js'; +import { BaseDeclarativeTool, BaseToolInvocation, Kind } from '../tools.js'; +import { ToolNames, ToolDisplayNames } from '../tool-names.js'; import type { ToolResult, ToolResultDisplay, AgentResultDisplay, -} from './tools.js'; -import { ToolConfirmationOutcome } from './tools.js'; +} from '../tools.js'; +import { ToolConfirmationOutcome } from '../tools.js'; import type { ToolCallConfirmationDetails, ToolConfirmationPayload, -} from './tools.js'; -import type { Config } from '../config/config.js'; -import type { SubagentManager } from '../subagents/subagent-manager.js'; -import type { SubagentConfig } from '../subagents/types.js'; -import { AgentTerminateMode } from '../agents/runtime/agent-types.js'; -import { ContextState } from '../agents/runtime/agent-headless.js'; -import { EXCLUDED_TOOLS_FOR_SUBAGENTS } from '../agents/runtime/agent-core.js'; +} from '../tools.js'; +import type { Config } from '../../config/config.js'; +import type { SubagentManager } from '../../subagents/subagent-manager.js'; +import type { SubagentConfig } from '../../subagents/types.js'; +import { AgentTerminateMode } from '../../agents/runtime/agent-types.js'; +import type { + PromptConfig, + RunConfig, + ToolConfig, +} from '../../agents/runtime/agent-types.js'; +import { + AgentHeadless, + ContextState, +} from '../../agents/runtime/agent-headless.js'; +import type { Content, FunctionDeclaration } from '@google/genai'; +import { + FORK_AGENT, + FORK_PLACEHOLDER_RESULT, + buildForkedMessages, + buildChildMessage, + isInForkExecution, + runInForkContext, +} from './fork-subagent.js'; import { AgentEventEmitter, AgentEventType, -} from '../agents/runtime/agent-events.js'; +} from '../../agents/runtime/agent-events.js'; import type { AgentToolCallEvent, AgentToolResultEvent, AgentFinishEvent, AgentErrorEvent, AgentApprovalRequestEvent, -} from '../agents/runtime/agent-events.js'; -import { BuiltinAgentRegistry } from '../subagents/builtin-agents.js'; -import { createDebugLogger } from '../utils/debugLogger.js'; -import { PermissionMode } from '../hooks/types.js'; -import type { StopHookOutput } from '../hooks/types.js'; -import { ApprovalMode } from '../config/config.js'; +} from '../../agents/runtime/agent-events.js'; +import { BuiltinAgentRegistry } from '../../subagents/builtin-agents.js'; +import { createDebugLogger } from '../../utils/debugLogger.js'; +import { PermissionMode } from '../../hooks/types.js'; +import type { StopHookOutput } from '../../hooks/types.js'; +import { ApprovalMode } from '../../config/config.js'; export interface AgentParams { description: string; @@ -572,141 +588,307 @@ class AgentToolInvocation extends BaseToolInvocation { return this.params.description; } + /** + * Creates a fork subagent that inherits the parent's conversation context + * and cache-safe generation params. + */ + private async createForkSubagent(agentConfig: Config): Promise<{ + subagent: AgentHeadless; + taskPrompt: string; + }> { + const geminiClient = this.config.getGeminiClient(); + const rawHistory = geminiClient ? geminiClient.getHistory(true) : []; + + // Build the history that will seed the fork's chat. Must end with a + // model message so agent-headless can send the task_prompt as a user + // message without creating consecutive user messages. + let initialMessages: Content[] | undefined; + let taskPrompt: string | undefined; + if (rawHistory.length > 0) { + const lastMessage = rawHistory[rawHistory.length - 1]; + if (lastMessage.role === 'model') { + const forkedMessages = buildForkedMessages( + this.params.prompt, + lastMessage, + ); + if (forkedMessages.length > 0) { + // Model had function calls: append tool responses + directive, + // then a model ack so history ends with model. + initialMessages = [ + ...rawHistory.slice(0, -1), + ...forkedMessages, + { + role: 'model' as const, + parts: [{ text: 'Understood. Executing directive now.' }], + }, + ]; + // task_prompt is a trigger to start execution + taskPrompt = 'Begin.'; + } else { + // Model had no function calls: history ends with model, + // directive goes via task_prompt. + initialMessages = [...rawHistory]; + } + } else { + // History ends with user (unusual) — drop the trailing user + // message to avoid consecutive user messages when agent-headless + // sends the task_prompt. + initialMessages = rawHistory.slice(0, -1); + } + } + + // Default: directive with fork boilerplate as task_prompt + if (!taskPrompt) { + taskPrompt = buildChildMessage(this.params.prompt); + } + + // Read the parent's live generationConfig (systemInstruction + tool + // declarations) so the fork's API requests share the parent's exact + // cache prefix for DashScope prompt caching. When the client isn't + // available (first turn edge case), fall back to the fork agent's own + // system prompt and wildcard tools. + let promptConfig: PromptConfig; + let toolConfig: ToolConfig; + + const generationConfig = geminiClient?.getChat().getGenerationConfig(); + if (generationConfig?.systemInstruction) { + // Inline FunctionDeclaration[] from the parent — passed verbatim + // including `agent` itself so the fork's tool-name set matches the + // parent's. prepareTools bypasses the exclusion filter for inline + // decls; `isInForkExecution()` (ALS-based) is the sole + // recursive-fork block at runtime. + const parentToolDecls: FunctionDeclaration[] = + ( + generationConfig.tools as Array<{ + functionDeclarations?: FunctionDeclaration[]; + }> + )?.flatMap((t) => t.functionDeclarations ?? []) ?? []; + + promptConfig = { + renderedSystemPrompt: generationConfig.systemInstruction as + | string + | Content, + initialMessages, + }; + toolConfig = { + tools: + parentToolDecls.length > 0 ? parentToolDecls : (['*'] as string[]), + }; + } else { + promptConfig = { + systemPrompt: FORK_AGENT.systemPrompt, + initialMessages, + }; + toolConfig = { tools: ['*'] }; + } + + const subagent = await AgentHeadless.create( + FORK_AGENT.name, + agentConfig, + promptConfig, + {}, + {} as RunConfig, + toolConfig, + this.eventEmitter, + ); + + return { subagent, taskPrompt }; + } + + /** + * Runs a subagent with start/stop hook lifecycle, updating the display + * as execution progresses. + */ + private async runSubagentWithHooks( + subagent: AgentHeadless, + contextState: ContextState, + opts: { + agentId: string; + agentType: string; + resolvedMode: PermissionMode; + signal?: AbortSignal; + updateOutput?: (output: ToolResultDisplay) => void; + }, + ): Promise { + const { agentId, agentType, resolvedMode, signal, updateOutput } = opts; + const hookSystem = this.config.getHookSystem(); + + try { + if (hookSystem) { + try { + const startHookOutput = await hookSystem.fireSubagentStartEvent( + agentId, + agentType, + resolvedMode, + signal, + ); + + // Inject additional context from hook output into subagent context + const additionalContext = startHookOutput?.getAdditionalContext(); + if (additionalContext) { + contextState.set('hook_context', additionalContext); + } + } catch (hookError) { + debugLogger.warn( + `[Agent] SubagentStart hook failed, continuing execution: ${hookError}`, + ); + } + } + + // Execute the subagent (blocking) + await subagent.execute(contextState, signal); + + // Fire SubagentStop hook after execution and handle block decisions + if (hookSystem && !signal?.aborted) { + const transcriptPath = this.config.getTranscriptPath(); + let stopHookActive = false; + + // Loop to handle "block" decisions (prevent subagent from stopping) + let continueExecution = true; + let iterationCount = 0; + const maxIterations = 5; // Prevent infinite loops from hook misconfigurations + + while (continueExecution) { + iterationCount++; + + // Safety check to prevent infinite loops + if (iterationCount >= maxIterations) { + debugLogger.warn( + `[TaskTool] SubagentStop hook reached maximum iterations (${maxIterations}), forcing stop to prevent infinite loop`, + ); + continueExecution = false; + break; + } + + try { + const stopHookOutput = await hookSystem.fireSubagentStopEvent( + agentId, + agentType, + transcriptPath, + subagent.getFinalText(), + stopHookActive, + resolvedMode, + signal, + ); + + const typedStopOutput = stopHookOutput as + | StopHookOutput + | undefined; + + if ( + typedStopOutput?.isBlockingDecision() || + typedStopOutput?.shouldStopExecution() + ) { + // Feed the reason back to the subagent and continue execution + const continueReason = typedStopOutput.getEffectiveReason(); + stopHookActive = true; + + const continueContext = new ContextState(); + continueContext.set('task_prompt', continueReason); + await subagent.execute(continueContext, signal); + + if (signal?.aborted) { + continueExecution = false; + } + // Loop continues to re-check SubagentStop hook + } else { + continueExecution = false; + } + } catch (hookError) { + debugLogger.warn( + `[TaskTool] SubagentStop hook failed, allowing stop: ${hookError}`, + ); + continueExecution = false; + } + } + } + + // Get the results + const finalText = subagent.getFinalText(); + const terminateMode = subagent.getTerminateMode(); + const success = terminateMode === AgentTerminateMode.GOAL; + const executionSummary = subagent.getExecutionSummary(); + + if (signal?.aborted) { + this.updateDisplay( + { + status: 'cancelled', + terminateReason: 'Agent was cancelled by user', + executionSummary, + }, + updateOutput, + ); + } else { + this.updateDisplay( + { + status: success ? 'completed' : 'failed', + terminateReason: terminateMode, + result: finalText, + executionSummary, + }, + updateOutput, + ); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + debugLogger.error( + `[AgentTool] Error inside subagent background task: ${errorMessage}`, + ); + this.updateDisplay( + { + status: 'failed', + terminateReason: `Failed to run subagent: ${errorMessage}`, + }, + updateOutput, + ); + } + } + async execute( signal?: AbortSignal, updateOutput?: (output: ToolResultDisplay) => void, ): Promise { try { + const isFork = !this.params.subagent_type; let subagentConfig: SubagentConfig; - let extraHistory: Array | undefined; - let forkPlaceholderResult: string | undefined; - let forkTaskPrompt: string | undefined; - let forkGenerationConfig: - | import('@google/genai').GenerateContentConfig - | undefined; - let forkToolsOverride: - | Array - | undefined; - if (!this.params.subagent_type) { - const { - FORK_AGENT, - FORK_PLACEHOLDER_RESULT, - buildForkedMessages, - buildChildMessage, - isInForkChild, - } = await import('../agents/runtime/forkSubagent.js'); - forkPlaceholderResult = FORK_PLACEHOLDER_RESULT; + if (isFork) { subagentConfig = FORK_AGENT; - // Retrieve the parent's cached generationConfig (systemInstruction + - // tools) so the fork's API requests share the same prefix for - // DashScope prompt cache hits. - const { getCacheSafeParams } = await import('../utils/forkedAgent.js'); - const cacheSafeParams = getCacheSafeParams(); - if (cacheSafeParams) { - forkGenerationConfig = cacheSafeParams.generationConfig; - const tools = cacheSafeParams.generationConfig.tools; - if (tools && tools.length > 0) { - forkToolsOverride = tools - .flatMap( - (t: import('@google/genai').ToolUnion) => - ( - t as { - functionDeclarations?: Array< - import('@google/genai').FunctionDeclaration - >; - } - ).functionDeclarations ?? [], - ) - .filter( - (decl: import('@google/genai').FunctionDeclaration) => - !(decl.name && EXCLUDED_TOOLS_FOR_SUBAGENTS.has(decl.name)), - ); - } - } - - const geminiClient = this.config.getGeminiClient(); - if (geminiClient) { - const rawHistory = geminiClient.getHistory(true); - - if (isInForkChild(rawHistory)) { - const errorDisplay = { + // Recursive-fork guard. A fork child's reasoning loop runs inside + // an AsyncLocalStorage frame set by `runInForkContext`; when its + // model calls the `agent` tool, this check fires before any history + // or config is touched. + if (isInForkExecution()) { + return { + llmContent: + 'Error: Cannot create a fork from within an existing fork child. Please execute tasks directly.', + returnDisplay: { type: 'task_execution' as const, subagentName: FORK_AGENT.name, taskDescription: this.params.description, taskPrompt: this.params.prompt, status: 'failed' as const, terminateReason: 'Recursive forking is not allowed', - }; - - return { - llmContent: - 'Error: Cannot create a fork from within an existing fork child. Please execute tasks directly.', - returnDisplay: errorDisplay, - }; - } - - // Build extraHistory ensuring it ends with a model message so - // agent-headless can send the task_prompt as a user message - // without creating consecutive user messages. - if (rawHistory.length > 0) { - const lastMessage = rawHistory[rawHistory.length - 1]; - if (lastMessage.role === 'model') { - const forkedMessages = buildForkedMessages( - this.params.prompt, - lastMessage, - ); - if (forkedMessages.length > 0) { - // Model had function calls: append tool responses + directive, - // then a model ack so history ends with model. - extraHistory = [ - ...rawHistory.slice(0, -1), - ...forkedMessages, - { - role: 'model' as const, - parts: [{ text: 'Understood. Executing directive now.' }], - }, - ]; - // task_prompt is a trigger to start execution - forkTaskPrompt = 'Begin.'; - } else { - // Model had no function calls: history ends with model, - // directive goes via task_prompt. - extraHistory = [...rawHistory]; - } - } else { - // History ends with user (unusual) — drop the trailing user - // message to avoid consecutive user messages when agent-headless - // sends the task_prompt. - extraHistory = rawHistory.slice(0, -1); - } - } - } - - // Default: directive with fork boilerplate as task_prompt - if (!forkTaskPrompt) { - forkTaskPrompt = buildChildMessage(this.params.prompt); + }, + }; } } else { - // Load the subagent configuration const loadedConfig = await this.subagentManager.loadSubagent( - this.params.subagent_type, + this.params.subagent_type!, ); - if (!loadedConfig) { - const errorDisplay = { - type: 'task_execution' as const, - subagentName: this.params.subagent_type, - taskDescription: this.params.description, - taskPrompt: this.params.prompt, - status: 'failed' as const, - terminateReason: `Subagent "${this.params.subagent_type}" not found`, - }; - return { llmContent: `Subagent "${this.params.subagent_type}" not found`, - returnDisplay: errorDisplay, + returnDisplay: { + type: 'task_execution' as const, + subagentName: this.params.subagent_type!, + taskDescription: this.params.description, + taskPrompt: this.params.prompt, + status: 'failed' as const, + terminateReason: `Subagent "${this.params.subagent_type}" not found`, + }, }; } subagentConfig = loadedConfig; @@ -721,198 +903,64 @@ class AgentToolInvocation extends BaseToolInvocation { status: 'running' as const, subagentColor: subagentConfig.color, }; - - // Set up event listeners for real-time updates this.setupEventListeners(updateOutput); - - // Send initial display if (updateOutput) { updateOutput(this.currentDisplay); } + // Resolve the subagent's permission mode before creating it const resolvedMode = resolveSubagentApprovalMode( this.config.getApprovalMode(), subagentConfig.approvalMode, this.config.isTrustedFolder(), ); - - // Create a config override with the resolved approval mode so the - // subagent's tool scheduler uses the correct mode for permission checks. const resolvedApprovalMode = permissionModeToApprovalMode(resolvedMode); const agentConfig = resolvedApprovalMode !== this.config.getApprovalMode() ? createApprovalModeOverride(this.config, resolvedApprovalMode) : this.config; - const subagent = await this.subagentManager.createAgentHeadless( - subagentConfig, - agentConfig, - { eventEmitter: this.eventEmitter }, - ); + // Create the subagent. Fork bypasses SubagentManager because its + // runtime configs are synthesized from the parent's cache-safe params. + let subagent: AgentHeadless; + let taskPrompt: string; + + if (isFork) { + const fork = await this.createForkSubagent(agentConfig); + subagent = fork.subagent; + taskPrompt = fork.taskPrompt; + } else { + subagent = await this.subagentManager.createAgentHeadless( + subagentConfig, + agentConfig, + { eventEmitter: this.eventEmitter }, + ); + taskPrompt = this.params.prompt; + } - // Create context state with the task prompt - // For fork agents, use the fork directive (with boilerplate) as the task - // prompt so it's sent as the first user message by agent-headless. const contextState = new ContextState(); - contextState.set('task_prompt', forkTaskPrompt || this.params.prompt); + contextState.set('task_prompt', taskPrompt); - // Fire SubagentStart hook before execution - const hookSystem = this.config.getHookSystem(); - const agentId = `${subagentConfig.name}-${Date.now()}`; - const agentType = this.params.subagent_type || subagentConfig.name; - - const executeSubagent = async () => { - try { - if (hookSystem) { - try { - const startHookOutput = await hookSystem.fireSubagentStartEvent( - agentId, - agentType, - resolvedMode, - signal, - ); - - // Inject additional context from hook output into subagent context - const additionalContext = startHookOutput?.getAdditionalContext(); - if (additionalContext) { - contextState.set('hook_context', additionalContext); - } - } catch (hookError) { - debugLogger.warn( - `[Agent] SubagentStart hook failed, continuing execution: ${hookError}`, - ); - } - } - - // Execute the subagent (blocking) - await subagent.execute(contextState, signal, { - extraHistory, - generationConfigOverride: forkGenerationConfig, - toolsOverride: forkToolsOverride, - skipEnvHistory: !!extraHistory && extraHistory.length > 0, - }); - - // Fire SubagentStop hook after execution and handle block decisions - if (hookSystem && !signal?.aborted) { - const transcriptPath = this.config.getTranscriptPath(); - let stopHookActive = false; - - // Loop to handle "block" decisions (prevent subagent from stopping) - let continueExecution = true; - let iterationCount = 0; - const maxIterations = 5; // Prevent infinite loops from hook misconfigurations - - while (continueExecution) { - iterationCount++; - - // Safety check to prevent infinite loops - if (iterationCount >= maxIterations) { - debugLogger.warn( - `[TaskTool] SubagentStop hook reached maximum iterations (${maxIterations}), forcing stop to prevent infinite loop`, - ); - continueExecution = false; - break; - } - - try { - const stopHookOutput = await hookSystem.fireSubagentStopEvent( - agentId, - agentType, - transcriptPath, - subagent.getFinalText(), - stopHookActive, - resolvedMode, - signal, - ); - - const typedStopOutput = stopHookOutput as - | StopHookOutput - | undefined; - - if ( - typedStopOutput?.isBlockingDecision() || - typedStopOutput?.shouldStopExecution() - ) { - // Feed the reason back to the subagent and continue execution - const continueReason = typedStopOutput.getEffectiveReason(); - stopHookActive = true; - - const continueContext = new ContextState(); - continueContext.set('task_prompt', continueReason); - await subagent.execute(continueContext, signal, { - extraHistory, - generationConfigOverride: forkGenerationConfig, - toolsOverride: forkToolsOverride, - skipEnvHistory: !!extraHistory && extraHistory.length > 0, - }); - - if (signal?.aborted) { - continueExecution = false; - } - // Loop continues to re-check SubagentStop hook - } else { - continueExecution = false; - } - } catch (hookError) { - debugLogger.warn( - `[TaskTool] SubagentStop hook failed, allowing stop: ${hookError}`, - ); - continueExecution = false; - } - } - } - - // Get the results - const finalText = subagent.getFinalText(); - const terminateMode = subagent.getTerminateMode(); - const success = terminateMode === AgentTerminateMode.GOAL; - const executionSummary = subagent.getExecutionSummary(); - - if (signal?.aborted) { - this.updateDisplay( - { - status: 'cancelled', - terminateReason: 'Agent was cancelled by user', - executionSummary, - }, - updateOutput, - ); - } else { - this.updateDisplay( - { - status: success ? 'completed' : 'failed', - terminateReason: terminateMode, - result: finalText, - executionSummary, - }, - updateOutput, - ); - } - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - debugLogger.error( - `[AgentTool] Error inside subagent background task: ${errorMessage}`, - ); - this.updateDisplay( - { - status: 'failed', - terminateReason: `Failed to run subagent: ${errorMessage}`, - }, - updateOutput, - ); - } + const hookOpts = { + agentId: `${subagentConfig.name}-${Date.now()}`, + agentType: this.params.subagent_type || subagentConfig.name, + resolvedMode, + signal, + updateOutput, }; - if (!this.params.subagent_type) { - // Background fork execution - void executeSubagent(); + if (isFork) { + // Background fork execution. Run under an AsyncLocalStorage frame so + // nested `agent` tool calls by the fork's model can be detected. + void runInForkContext(() => + this.runSubagentWithHooks(subagent, contextState, hookOpts), + ); return { - llmContent: [{ text: forkPlaceholderResult! }], + llmContent: [{ text: FORK_PLACEHOLDER_RESULT }], returnDisplay: this.currentDisplay!, }; } else { - await executeSubagent(); + await this.runSubagentWithHooks(subagent, contextState, hookOpts); const finalText = subagent.getFinalText(); const terminateMode = subagent.getTerminateMode(); if (terminateMode === AgentTerminateMode.ERROR) { diff --git a/packages/core/src/agents/runtime/forkSubagent.ts b/packages/core/src/tools/agent/fork-subagent.ts similarity index 82% rename from packages/core/src/agents/runtime/forkSubagent.ts rename to packages/core/src/tools/agent/fork-subagent.ts index 222d7a6a7..e442f727c 100644 --- a/packages/core/src/agents/runtime/forkSubagent.ts +++ b/packages/core/src/tools/agent/fork-subagent.ts @@ -1,3 +1,4 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; import type { Content } from '@google/genai'; export const FORK_SUBAGENT_TYPE = 'fork'; @@ -15,13 +16,25 @@ export const FORK_AGENT = { level: 'session' as const, }; -export function isInForkChild(messages: Content[]): boolean { - return messages.some((m) => { - if (m.role !== 'user') return false; - return m.parts?.some( - (part) => part.text && part.text.includes(`<${FORK_BOILERPLATE_TAG}>`), - ); - }); +// 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 +// fork subagent" via AsyncLocalStorage when dispatching; AgentTool.execute() +// reads the marker and rejects nested fork calls. +// +// Why ALS and not a history scan: the nested AgentTool's `this.config` is the +// main process Config, so `getGeminiClient().getHistory()` returns the parent +// conversation — not the fork child's chat — and cannot be used to detect +// nesting. Async context propagation works naturally across the fork's +// await chain and is scoped per-execution. +const forkExecutionStorage = new AsyncLocalStorage<{ readonly marker: true }>(); + +export function runInForkContext(fn: () => Promise): Promise { + return forkExecutionStorage.run({ marker: true }, fn); +} + +export function isInForkExecution(): boolean { + return forkExecutionStorage.getStore() !== undefined; } export const FORK_PLACEHOLDER_RESULT = diff --git a/packages/core/src/utils/forkedAgent.ts b/packages/core/src/utils/forkedAgent.ts index 790f4ca61..2f0de662a 100644 --- a/packages/core/src/utils/forkedAgent.ts +++ b/packages/core/src/utils/forkedAgent.ts @@ -226,8 +226,6 @@ export interface AgentPathParams { systemPrompt: string; /** Model override (defaults to config.getFastModel() ?? config.getModel()). */ model?: string; - /** Sampling temperature (default: 0 for deterministic output). */ - temp?: number; /** Maximum number of agent turns (default: unlimited). */ maxTurns?: number; /** Maximum execution time in minutes (default: unlimited). */ @@ -244,11 +242,6 @@ export interface AgentPathParams { * Must end with a `model` role entry; call buildAgentHistory() to enforce this. */ extraHistory?: Content[]; - /** - * Skip env bootstrap injection in createChat() when extraHistory already - * contains the env context from the parent conversation. - */ - skipEnvHistory?: boolean; /** External cancellation signal. */ abortSignal?: AbortSignal; } @@ -393,11 +386,13 @@ export async function runForkedAgent( } }); - const promptConfig: PromptConfig = { systemPrompt: params.systemPrompt }; + const promptConfig: PromptConfig = { + systemPrompt: params.systemPrompt, + initialMessages: params.extraHistory, + }; const modelConfig: ModelConfig = { model: params.model ?? params.config.getFastModel() ?? params.config.getModel(), - temp: params.temp ?? 0, }; const runConfig: RunConfig = { max_turns: params.maxTurns, @@ -418,10 +413,7 @@ export async function runForkedAgent( const context = new ContextState(); context.set('task_prompt', params.taskPrompt); - await headless.execute(context, params.abortSignal, { - extraHistory: params.extraHistory, - skipEnvHistory: params.skipEnvHistory, - }); + await headless.execute(context, params.abortSignal); const terminateReason = headless.getTerminateMode(); const finalText = headless.getFinalText() || undefined;