diff --git a/docs/users/features/memory.md b/docs/users/features/memory.md
index 077082a2714..cdd7951c7d4 100644
--- a/docs/users/features/memory.md
+++ b/docs/users/features/memory.md
@@ -2,7 +2,7 @@
Every Qwen Code session starts with a fresh context window. Two mechanisms carry knowledge across sessions so you don't have to re-explain yourself every time:
-- **QWEN.md** — instructions *you* write once and Qwen reads every session
+- **QWEN.md** — instructions _you_ write once and Qwen reads every session
- **Auto-memory** — notes Qwen writes itself based on what it learns from you
---
@@ -24,9 +24,9 @@ Don't include things Qwen can figure out by reading your code. QWEN.md works bes
### Where to create QWEN.md
-| File | Who it applies to |
-|---|---|
-| `~/.qwen/QWEN.md` | You, across all your projects |
+| File | Who it applies to |
+| ----------------------------- | --------------------------------------------- |
+| `~/.qwen/QWEN.md` | You, across all your projects |
| `QWEN.md` in the project root | Your whole team (commit it to source control) |
You can have both. Qwen loads all QWEN.md files it finds when you start a session — your personal one plus any in the project.
@@ -45,6 +45,7 @@ You can point QWEN.md at other files so Qwen reads them too:
See @README.md for project overview.
# Conventions
+
- Git workflow: @docs/git-workflow.md
```
@@ -62,12 +63,12 @@ This is different from QWEN.md: you don't write it, Qwen does.
Qwen looks for four kinds of things worth remembering:
-| What | Examples |
-|---|---|
-| **About you** | Your role, background, how you like to work |
-| **Your feedback** | Corrections you made, approaches you confirmed |
-| **Project context** | Ongoing work, decisions, goals not obvious from the code |
-| **External references** | Dashboards, ticket trackers, docs links you mentioned |
+| What | Examples |
+| ----------------------- | -------------------------------------------------------- |
+| **About you** | Your role, background, how you like to work |
+| **Your feedback** | Corrections you made, approaches you confirmed |
+| **Project context** | Ongoing work, decisions, goals not obvious from the code |
+| **External references** | Dashboards, ticket trackers, docs links you mentioned |
Qwen doesn't save everything — only things that would actually be useful next time.
@@ -150,6 +151,7 @@ Runs the memory cleanup now instead of waiting for the automatic schedule:
Open `/memory` to see which files are loaded. If your file isn't listed, Qwen can't see it — make sure it's in the project root or `~/.qwen/`.
Instructions work better when they're specific:
+
- ✓ `Use 2-space indentation for TypeScript files`
- ✗ `Format code nicely`
diff --git a/packages/cli/src/ui/components/MemoryDialog.tsx b/packages/cli/src/ui/components/MemoryDialog.tsx
index c0e781e49c3..8fca1ecb080 100644
--- a/packages/cli/src/ui/components/MemoryDialog.tsx
+++ b/packages/cli/src/ui/components/MemoryDialog.tsx
@@ -393,7 +393,9 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) {
{index + 1}. {item.label}
{item.description ? (
- {` ${item.description}`}
+ {` ${item.description}`}
) : null}
);
diff --git a/packages/cli/src/ui/hooks/useReactToolScheduler.ts b/packages/cli/src/ui/hooks/useReactToolScheduler.ts
index 3991823d55d..3bf3083797b 100644
--- a/packages/cli/src/ui/hooks/useReactToolScheduler.ts
+++ b/packages/cli/src/ui/hooks/useReactToolScheduler.ts
@@ -341,7 +341,9 @@ export function mapToDisplay(
return {
type: 'tool_group',
tools: toolDisplays,
- memoryWriteCount: toolDisplays.filter((t) => t.isMemoryOp === 'write').length || undefined,
- memoryReadCount: toolDisplays.filter((t) => t.isMemoryOp === 'read').length || undefined,
+ memoryWriteCount:
+ toolDisplays.filter((t) => t.isMemoryOp === 'write').length || undefined,
+ memoryReadCount:
+ toolDisplays.filter((t) => t.isMemoryOp === 'read').length || undefined,
};
}
diff --git a/packages/cli/src/utils/commands.test.ts b/packages/cli/src/utils/commands.test.ts
index 07c5c9fa8f1..fb9410b386f 100644
--- a/packages/cli/src/utils/commands.test.ts
+++ b/packages/cli/src/utils/commands.test.ts
@@ -71,10 +71,7 @@ describe('parseSlashCommand', () => {
});
it('should parse a subcommand with arguments', () => {
- const result = parseSlashCommand(
- '/config set theme dark',
- mockCommands,
- );
+ const result = parseSlashCommand('/config set theme dark', mockCommands);
expect(result.commandToExecute?.name).toBe('set');
expect(result.args).toBe('theme dark');
expect(result.canonicalPath).toEqual(['config', 'set']);
diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts
index b4b73ee70ef..97627b7e613 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 01ff1b040e2..89760ee0bb1 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 55fb7ba2412..ac02f80dfba 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 1f6f15343c9..e6c4e4e40dc 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 a86c8e0d71b..5c05935014f 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 b8a23b0d339..0a7cb78502c 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 51593bb9eaf..248dfedffb2 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/const.test.ts b/packages/core/src/memory/const.test.ts
index 3f6f449b88e..d20d7a509fa 100644
--- a/packages/core/src/memory/const.test.ts
+++ b/packages/core/src/memory/const.test.ts
@@ -23,27 +23,26 @@ vi.mock(import('node:fs/promises'), async (importOriginal) => {
vi.mock('os');
-
describe('setGeminiMdFilename', () => {
- it('should update currentGeminiMdFilename when a valid new name is provided', () => {
- const newName = 'CUSTOM_CONTEXT.md';
- setGeminiMdFilename(newName);
- expect(getCurrentGeminiMdFilename()).toBe(newName);
- });
+ it('should update currentGeminiMdFilename when a valid new name is provided', () => {
+ const newName = 'CUSTOM_CONTEXT.md';
+ setGeminiMdFilename(newName);
+ expect(getCurrentGeminiMdFilename()).toBe(newName);
+ });
- it('should not update currentGeminiMdFilename if the new name is empty or whitespace', () => {
- const initialName = getCurrentGeminiMdFilename(); // Get current before trying to change
- setGeminiMdFilename(' ');
- expect(getCurrentGeminiMdFilename()).toBe(initialName);
+ it('should not update currentGeminiMdFilename if the new name is empty or whitespace', () => {
+ const initialName = getCurrentGeminiMdFilename(); // Get current before trying to change
+ setGeminiMdFilename(' ');
+ expect(getCurrentGeminiMdFilename()).toBe(initialName);
- setGeminiMdFilename('');
- expect(getCurrentGeminiMdFilename()).toBe(initialName);
- });
+ setGeminiMdFilename('');
+ expect(getCurrentGeminiMdFilename()).toBe(initialName);
+ });
- it('should handle an array of filenames', () => {
- const newNames = ['CUSTOM_CONTEXT.md', 'ANOTHER_CONTEXT.md'];
- setGeminiMdFilename(newNames);
- expect(getCurrentGeminiMdFilename()).toBe('CUSTOM_CONTEXT.md');
- expect(getAllGeminiMdFilenames()).toEqual(newNames);
- });
+ it('should handle an array of filenames', () => {
+ const newNames = ['CUSTOM_CONTEXT.md', 'ANOTHER_CONTEXT.md'];
+ setGeminiMdFilename(newNames);
+ expect(getCurrentGeminiMdFilename()).toBe('CUSTOM_CONTEXT.md');
+ expect(getAllGeminiMdFilenames()).toEqual(newNames);
});
+});
diff --git a/packages/core/src/memory/extractionAgentPlanner.ts b/packages/core/src/memory/extractionAgentPlanner.ts
index 058682e0624..6a89127986c 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/memory/indexer.test.ts b/packages/core/src/memory/indexer.test.ts
index 707b171cb26..9becaf34695 100644
--- a/packages/core/src/memory/indexer.test.ts
+++ b/packages/core/src/memory/indexer.test.ts
@@ -23,7 +23,10 @@ describe('managed auto-memory indexer', () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'auto-memory-indexer-'));
projectRoot = path.join(tempDir, 'project');
await fs.mkdir(projectRoot, { recursive: true });
- await ensureAutoMemoryScaffold(projectRoot, new Date('2026-04-01T00:00:00.000Z'));
+ await ensureAutoMemoryScaffold(
+ projectRoot,
+ new Date('2026-04-01T00:00:00.000Z'),
+ );
});
afterEach(async () => {
@@ -49,9 +52,7 @@ describe('managed auto-memory indexer', () => {
},
]);
- expect(content).toBe(
- '- [User Memory](user/terse.md) — User profile',
- );
+ expect(content).toBe('- [User Memory](user/terse.md) — User profile');
});
it('rewrites MEMORY.md from topic file contents', async () => {
@@ -76,7 +77,10 @@ describe('managed auto-memory indexer', () => {
await rebuildManagedAutoMemoryIndex(projectRoot);
- const index = await fs.readFile(getAutoMemoryIndexPath(projectRoot), 'utf-8');
+ const index = await fs.readFile(
+ getAutoMemoryIndexPath(projectRoot),
+ 'utf-8',
+ );
expect(index).toContain('[Project Memory](project/repo-workspaces.md)');
expect(index).toContain('The repo uses pnpm workspaces.');
});
diff --git a/packages/core/src/memory/indexer.ts b/packages/core/src/memory/indexer.ts
index 3b7cde10b9b..a4297d2dada 100644
--- a/packages/core/src/memory/indexer.ts
+++ b/packages/core/src/memory/indexer.ts
@@ -6,7 +6,10 @@
import * as fs from 'node:fs/promises';
import { getAutoMemoryIndexPath, getAutoMemoryMetadataPath } from './paths.js';
-import { scanAutoMemoryTopicDocuments, type ScannedAutoMemoryDocument } from './scan.js';
+import {
+ scanAutoMemoryTopicDocuments,
+ type ScannedAutoMemoryDocument,
+} from './scan.js';
import type { AutoMemoryMetadata } from './types.js';
const MAX_INDEX_LINE_CHARS = 150;
@@ -22,7 +25,10 @@ function truncateIndexLine(text: string): string {
export function buildManagedAutoMemoryIndex(
docs: ScannedAutoMemoryDocument[],
- _metadata?: Pick,
+ _metadata?: Pick<
+ AutoMemoryMetadata,
+ 'updatedAt' | 'lastDreamAt' | 'lastDreamSessionId'
+ >,
): string {
const raw = docs
.map((doc) =>
@@ -34,7 +40,9 @@ export function buildManagedAutoMemoryIndex(
const lines = raw.split('\n');
const wasLineTruncated = lines.length > MAX_INDEX_LINES;
- let truncated = wasLineTruncated ? lines.slice(0, MAX_INDEX_LINES).join('\n') : raw;
+ let truncated = wasLineTruncated
+ ? lines.slice(0, MAX_INDEX_LINES).join('\n')
+ : raw;
if (truncated.length > MAX_INDEX_BYTES) {
const cutAt = truncated.lastIndexOf('\n', MAX_INDEX_BYTES);
@@ -52,7 +60,10 @@ async function readAutoMemoryMetadata(
projectRoot: string,
): Promise {
try {
- const content = await fs.readFile(getAutoMemoryMetadataPath(projectRoot), 'utf-8');
+ const content = await fs.readFile(
+ getAutoMemoryMetadataPath(projectRoot),
+ 'utf-8',
+ );
return JSON.parse(content) as AutoMemoryMetadata;
} catch {
return undefined;
diff --git a/packages/core/src/memory/prompt.ts b/packages/core/src/memory/prompt.ts
index c584dafed8a..615aa25ddb6 100644
--- a/packages/core/src/memory/prompt.ts
+++ b/packages/core/src/memory/prompt.ts
@@ -143,7 +143,10 @@ function truncateManagedAutoMemoryIndex(indexContent: string): string {
: trimmed;
if (truncated.length > MAX_MANAGED_AUTO_MEMORY_INDEX_BYTES) {
- const cutAt = truncated.lastIndexOf('\n', MAX_MANAGED_AUTO_MEMORY_INDEX_BYTES);
+ const cutAt = truncated.lastIndexOf(
+ '\n',
+ MAX_MANAGED_AUTO_MEMORY_INDEX_BYTES,
+ );
truncated = truncated.slice(
0,
cutAt > 0 ? cutAt : MAX_MANAGED_AUTO_MEMORY_INDEX_BYTES,
@@ -231,6 +234,4 @@ export function appendManagedAutoMemoryToUserMemory(
return `${trimmedUserMemory}\n\n---\n\n${managedPrompt}`;
}
-export {
- MAX_MANAGED_AUTO_MEMORY_INDEX_LINES,
-};
\ No newline at end of file
+export { MAX_MANAGED_AUTO_MEMORY_INDEX_LINES };
diff --git a/packages/core/src/memory/recall.test.ts b/packages/core/src/memory/recall.test.ts
index 05d0750dc14..e6ff91fe8c2 100644
--- a/packages/core/src/memory/recall.test.ts
+++ b/packages/core/src/memory/recall.test.ts
@@ -129,4 +129,4 @@ describe('auto-memory relevant recall', () => {
'/tmp/user.md',
);
});
-});
\ No newline at end of file
+});
diff --git a/packages/core/src/memory/store.test.ts b/packages/core/src/memory/store.test.ts
index 3838f82cbec..0d02cf865d1 100644
--- a/packages/core/src/memory/store.test.ts
+++ b/packages/core/src/memory/store.test.ts
@@ -67,7 +67,10 @@ describe('auto-memory storage scaffold', () => {
const now = new Date('2026-04-01T08:00:00.000Z');
await ensureAutoMemoryScaffold(projectRoot, now);
- const index = await fs.readFile(getAutoMemoryIndexPath(projectRoot), 'utf-8');
+ const index = await fs.readFile(
+ getAutoMemoryIndexPath(projectRoot),
+ 'utf-8',
+ );
expect(index).toBe(createDefaultAutoMemoryIndex());
const metadata = JSON.parse(
@@ -82,20 +85,34 @@ describe('auto-memory storage scaffold', () => {
updatedAt: '2026-04-01T08:00:00.000Z',
});
- await expect(fs.stat(getAutoMemoryRoot(projectRoot))).resolves.toBeDefined();
- await expect(fs.access(getAutoMemoryTopicPath(projectRoot, 'user'))).rejects.toThrow();
+ await expect(
+ fs.stat(getAutoMemoryRoot(projectRoot)),
+ ).resolves.toBeDefined();
+ await expect(
+ fs.access(getAutoMemoryTopicPath(projectRoot, 'user')),
+ ).rejects.toThrow();
});
it('is idempotent and preserves existing index content', async () => {
- await ensureAutoMemoryScaffold(projectRoot, new Date('2026-04-01T08:00:00.000Z'));
+ await ensureAutoMemoryScaffold(
+ projectRoot,
+ new Date('2026-04-01T08:00:00.000Z'),
+ );
const customIndex = '# Existing Index\n\n- keep me\n';
- await fs.writeFile(getAutoMemoryIndexPath(projectRoot), customIndex, 'utf-8');
-
- await ensureAutoMemoryScaffold(projectRoot, new Date('2026-04-02T08:00:00.000Z'));
-
- await expect(fs.readFile(getAutoMemoryIndexPath(projectRoot), 'utf-8')).resolves.toBe(
+ await fs.writeFile(
+ getAutoMemoryIndexPath(projectRoot),
customIndex,
+ 'utf-8',
);
+
+ await ensureAutoMemoryScaffold(
+ projectRoot,
+ new Date('2026-04-02T08:00:00.000Z'),
+ );
+
+ await expect(
+ fs.readFile(getAutoMemoryIndexPath(projectRoot), 'utf-8'),
+ ).resolves.toBe(customIndex);
});
it('returns null when the auto-memory index does not exist yet', async () => {
@@ -106,4 +123,4 @@ describe('auto-memory storage scaffold', () => {
await ensureAutoMemoryScaffold(projectRoot);
await expect(readAutoMemoryIndex(projectRoot)).resolves.toBe('');
});
-});
\ No newline at end of file
+});
diff --git a/packages/core/src/memory/store.ts b/packages/core/src/memory/store.ts
index eb966d6744a..9f5924a3ee8 100644
--- a/packages/core/src/memory/store.ts
+++ b/packages/core/src/memory/store.ts
@@ -93,6 +93,4 @@ export async function readAutoMemoryIndex(
}
}
-export {
- AUTO_MEMORY_INDEX_FILENAME,
-};
\ No newline at end of file
+export { AUTO_MEMORY_INDEX_FILENAME };
diff --git a/packages/core/src/memory/types.ts b/packages/core/src/memory/types.ts
index 6e05930f6e0..1c46d3af7cd 100644
--- a/packages/core/src/memory/types.ts
+++ b/packages/core/src/memory/types.ts
@@ -40,4 +40,4 @@ export interface AutoMemoryExtractCursor {
sessionId?: string;
processedOffset?: number;
updatedAt: string;
-}
\ No newline at end of file
+}
diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts
index 1bfd09ced9f..9cef68ce49a 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 1a203cebf48..2a1b603f636 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 b7f2f045fc8..2ff98baf395 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;
}
- async execute(
- signal?: AbortSignal,
- updateOutput?: (output: ToolResultDisplay) => void,
- ): Promise {
+ /**
+ * 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 {
- 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;
- subagentConfig = FORK_AGENT;
+ if (hookSystem) {
+ try {
+ const startHookOutput = await hookSystem.fireSubagentStartEvent(
+ agentId,
+ agentType,
+ resolvedMode,
+ signal,
+ );
- // 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)),
- );
+ // 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}`,
+ );
}
+ }
- const geminiClient = this.config.getGeminiClient();
- if (geminiClient) {
- const rawHistory = geminiClient.getHistory(true);
+ // Execute the subagent (blocking)
+ await subagent.execute(contextState, signal);
- if (isInForkChild(rawHistory)) {
- const errorDisplay = {
- 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',
- };
+ // 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++;
- return {
- llmContent:
- 'Error: Cannot create a fork from within an existing fork child. Please execute tasks directly.',
- returnDisplay: errorDisplay,
- };
+ // 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;
}
- // 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];
+ 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 {
- // 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);
+ 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();
- // Default: directive with fork boilerplate as task_prompt
- if (!forkTaskPrompt) {
- forkTaskPrompt = buildChildMessage(this.params.prompt);
+ 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;
+
+ if (isFork) {
+ subagentConfig = FORK_AGENT;
+
+ // 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',
+ },
+ };
}
} 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 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);
-
- // 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,
- );
+ // 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;
- // 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;
- }
+ 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;
+ }
- 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;
- }
- }
- }
+ const contextState = new ContextState();
+ contextState.set('task_prompt', taskPrompt);
- // 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 222d7a6a7d2..e442f727ca8 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 790f4ca61d2..2f0de662a33 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;