mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 11:41:04 +00:00
refactor(core): move fork subagent params from execute() to construction time (#3255)
* refactor(core): move fork subagent params from execute() to construction time Identity-shaping fork inputs (parent history, generationConfig, tool decls, env-skip flag) were threaded through `AgentHeadless.execute()`'s options bag and re-passed by the SubagentStop hook retry loop. They belong on the agent's construction-time configs, not its per-invocation options. - PromptConfig gains `renderedSystemPrompt` (verbatim, bypasses templating and userMemory injection) and drops the `systemPrompt`/`initialMessages` XOR so fork can carry both. createChat skips env bootstrap when `initialMessages` is non-empty. - AgentHeadless.execute() shrinks to (context, signal?). Fork dispatch in agent.ts builds synthetic PromptConfig/ModelConfig/ToolConfig from the parent's cache-safe params and calls AgentHeadless.create directly (bypassing SubagentManager). Parent's tool decls flow through verbatim including the `agent` tool itself for cache parity. - Recursive-fork prevention switches from fork-side tool stripping to a runtime guard. The previous `isInForkChild(history)` helper was dead code (it scanned the main GeminiClient's history, not the fork child's chat). Replaced with `isInForkExecution()` backed by AsyncLocalStorage: the fork's background execution runs inside `runInForkContext`, and the ALS frame propagates through the standard async chain into nested AgentTool.execute() calls where the guard fires. * refactor(core): move agent tool files into dedicated tools/agent/ directory Move agent.ts, agent.test.ts, and fork-subagent.ts under tools/agent/ and update all import paths accordingly. * refactor(core): remove dead temp and top_p fields from ModelConfig These fields were never populated from subagent frontmatter and served no purpose in the fork path either. The ModelConfig interface retains only the actively-used model field. * refactor(core): read parent generation config directly instead of getCacheSafeParams Fork subagent now reads system instruction and tool declarations from the live GeminiChat via getGenerationConfig() instead of the global getCacheSafeParams() snapshot. This removes the cross-module coupling between the agent tool and the followup infrastructure. * fix(core): prevent duplicate tool declarations when toolConfig has only inline decls prepareTools() treated asStrings.length === 0 as "add all registry tools", which is correct when no tools are specified at all, but wrong when the caller provides only inline FunctionDeclaration[] (no string names). The fork path passes parent tool declarations as inline decls for cache parity, so prepareTools was adding the full registry set on top — duplicating every non-excluded tool. Add onlyInlineDecls.length === 0 to the condition so that pure-inline toolConfigs bypass the registry entirely. * refactor(core): remove dead temp and skipEnvHistory fields from AgentPathParams These fields were carried over from earlier designs but have no remaining effect after the fork subagent refactor: - `temp` was never forwarded into ModelConfig, which this PR already stripped of the temperature field. - `skipEnvHistory` is redundant with the auto-skip in `AgentCore.createChat`, which already bypasses env bootstrap whenever `initialMessages` is non-empty — the condition under which any caller would set this flag. Also drops the corresponding `skipEnvHistory: true` at the one caller in the memory extraction planner.
This commit is contained in:
parent
875f3ffeb4
commit
503c9c638d
13 changed files with 583 additions and 433 deletions
|
|
@ -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<GeminiChat | undefined> {
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -192,21 +192,8 @@ export class AgentHeadless {
|
|||
async execute(
|
||||
context: ContextState,
|
||||
externalSignal?: AbortSignal,
|
||||
options?: {
|
||||
extraHistory?: Array<import('@google/genai').Content>;
|
||||
/** Override generationConfig for cache sharing (fork subagent). */
|
||||
generationConfigOverride?: import('@google/genai').GenerateContentConfig;
|
||||
/** Override tool declarations for cache sharing (fork subagent). */
|
||||
toolsOverride?: Array<import('@google/genai').FunctionDeclaration>;
|
||||
/** Skip env bootstrap injection (fork already inherits parent env). */
|
||||
skipEnvHistory?: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
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!',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Config['getGeminiClient']>);
|
||||
|
||||
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;
|
||||
|
|
@ -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<AgentParams, ToolResult> {
|
|||
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<void> {
|
||||
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<ToolResult> {
|
||||
try {
|
||||
const isFork = !this.params.subagent_type;
|
||||
let subagentConfig: SubagentConfig;
|
||||
let extraHistory: Array<import('@google/genai').Content> | undefined;
|
||||
let forkPlaceholderResult: string | undefined;
|
||||
let forkTaskPrompt: string | undefined;
|
||||
let forkGenerationConfig:
|
||||
| import('@google/genai').GenerateContentConfig
|
||||
| undefined;
|
||||
let forkToolsOverride:
|
||||
| Array<import('@google/genai').FunctionDeclaration>
|
||||
| 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<AgentParams, ToolResult> {
|
|||
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) {
|
||||
|
|
@ -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<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return forkExecutionStorage.run({ marker: true }, fn);
|
||||
}
|
||||
|
||||
export function isInForkExecution(): boolean {
|
||||
return forkExecutionStorage.getStore() !== undefined;
|
||||
}
|
||||
|
||||
export const FORK_PLACEHOLDER_RESULT =
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue