Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions packages/cli/src/ui/components/hooks/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,7 @@ export function getHookShortDescription(eventName: string): string {
[HookEventName.PreToolUse]: t('Before tool execution'),
[HookEventName.PostToolUse]: t('After tool execution'),
[HookEventName.PostToolUseFailure]: t('After tool execution fails'),
[HookEventName.PostToolBatch]: t(
'After all tool calls in a batch resolve',
),
[HookEventName.PostToolBatch]: t('After all tool calls in a batch resolve'),
[HookEventName.Notification]: t('When notifications are sent'),
[HookEventName.UserPromptSubmit]: t('When the user submits a prompt'),
[HookEventName.SessionStart]: t('When a new session is started'),
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,7 @@ export interface ConfigParameters {
sessionTokenLimit?: number;
experimentalZedIntegration?: boolean;
cronEnabled?: boolean;
forkSubagentEnabled?: boolean;
computerUseEnabled?: boolean;
emitToolUseSummaries?: boolean;
listExtensions?: boolean;
Expand Down Expand Up @@ -1074,6 +1075,7 @@ export class Config {
private runtimeStatusEnabled = false;
private readonly experimentalZedIntegration: boolean = false;
private readonly cronEnabled: boolean = false;
private readonly forkSubagentEnabled: boolean = false;
private readonly computerUseEnabled: boolean = true;
private readonly emitToolUseSummaries: boolean = true;
private readonly chatRecordingEnabled: boolean;
Expand Down Expand Up @@ -1256,6 +1258,7 @@ export class Config {
this.experimentalZedIntegration =
params.experimentalZedIntegration ?? false;
this.cronEnabled = params.cronEnabled ?? false;
this.forkSubagentEnabled = params.forkSubagentEnabled ?? false;
this.computerUseEnabled = params.computerUseEnabled ?? true;
this.emitToolUseSummaries = params.emitToolUseSummaries ?? true;
this.listExtensions = params.listExtensions ?? false;
Expand Down Expand Up @@ -3192,6 +3195,10 @@ export class Config {
return this.cronEnabled;
}

isForkSubagentEnabled(): boolean {
if (process.env['QWEN_CODE_ENABLE_FORK_SUBAGENT'] === '1') return true;
return this.forkSubagentEnabled;
}
isComputerUseEnabled(): boolean {
return this.computerUseEnabled;
}
Expand Down
144 changes: 144 additions & 0 deletions packages/core/src/tools/agent/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ describe('AgentTool', () => {
getTranscriptPath: vi.fn().mockReturnValue('/test/transcript'),
getApprovalMode: vi.fn().mockReturnValue('default'),
isTrustedFolder: vi.fn().mockReturnValue(true),
isInteractive: vi.fn().mockReturnValue(false),
isForkSubagentEnabled: vi.fn().mockReturnValue(false),
getBackgroundTaskRegistry: vi.fn().mockReturnValue(stubRegistry),
getMonitorRegistry: vi.fn().mockReturnValue(stubMonitorRegistry),
getToolRegistry: vi.fn().mockReturnValue(stubToolRegistry),
Expand Down Expand Up @@ -235,6 +237,64 @@ describe('AgentTool', () => {
expect(failedAgentTool.description).toContain('general-purpose');
expect(failedAgentTool.description).toContain('Explore');
});

it('includes "When to fork" section in description when fork enabled + interactive', async () => {
(config as unknown as Record<string, unknown>)['isInteractive'] = vi
.fn()
.mockReturnValue(true);
(config as unknown as Record<string, unknown>)[
'isForkSubagentEnabled'
] = vi.fn().mockReturnValue(true);

const interactiveTool = new AgentTool(config);
await vi.runAllTimersAsync();

expect(interactiveTool.description).toContain('When to fork');
expect(interactiveTool.description).toContain("Don't peek");
expect(interactiveTool.description).toContain("Don't race");
expect(interactiveTool.description).toContain('Writing a fork prompt');
});

it('omits fork discipline but keeps "Writing the prompt" when non-interactive', async () => {
(config as unknown as Record<string, unknown>)['isInteractive'] = vi
.fn()
.mockReturnValue(false);
(config as unknown as Record<string, unknown>)[
'isForkSubagentEnabled'
] = vi.fn().mockReturnValue(false);

const nonInteractiveTool = new AgentTool(config);
await vi.runAllTimersAsync();

// Fork-specific sections must be absent
expect(nonInteractiveTool.description).not.toContain('When to fork');
expect(nonInteractiveTool.description).not.toContain("Don't peek");
expect(nonInteractiveTool.description).not.toContain("Don't race");
expect(nonInteractiveTool.description).not.toContain(
'Writing a fork prompt',
);

// "Writing the prompt" section is always present (useful for fresh agents too)
expect(nonInteractiveTool.description).toContain('Writing the prompt');
expect(nonInteractiveTool.description).toContain(
'Never delegate understanding',
);
});

it('omits fork discipline when interactive but fork flag is off', async () => {
(config as unknown as Record<string, unknown>)['isInteractive'] = vi
.fn()
.mockReturnValue(true);
(config as unknown as Record<string, unknown>)[
'isForkSubagentEnabled'
] = vi.fn().mockReturnValue(false);

const tool = new AgentTool(config);
await vi.runAllTimersAsync();

expect(tool.description).not.toContain('When to fork');
expect(tool.description).toContain('If omitted, the general-purpose agent is used');
});
});

describe('schema generation', () => {
Expand Down Expand Up @@ -782,6 +842,83 @@ describe('AgentTool', () => {
} as unknown as ReturnType<Config['getGeminiClient']>);

vi.mocked(AgentHeadless.create).mockResolvedValue(mockAgent);

// Fork requires interactive mode + feature flag — gate from isForkSubagentEnabled().
(config as unknown as Record<string, unknown>)['isInteractive'] = vi
.fn()
.mockReturnValue(true);
(config as unknown as Record<string, unknown>)[
'isForkSubagentEnabled'
] = vi.fn().mockReturnValue(true);
});

it('falls back to general-purpose when fork flag is off', async () => {
// Fork flag off — even though interactive
vi.mocked(
config.isForkSubagentEnabled as ReturnType<typeof vi.fn>,
).mockReturnValue(false);

const mockLoadedSubagent: SubagentConfig = {
name: 'general-purpose',
description: 'General-purpose agent',
systemPrompt: 'You are a general-purpose agent.',
level: 'builtin',
filePath: '<builtin:general-purpose>',
};
vi.mocked(mockSubagentManager.loadSubagent).mockResolvedValue(
mockLoadedSubagent,
);

const params: AgentParams = {
description: 'some task',
prompt: 'do the thing',
};

const invocation = (
agentTool as AgentToolWithProtectedMethods
).createInvocation(params);
await invocation.execute();

// Should load general-purpose, not fork
expect(mockSubagentManager.loadSubagent).toHaveBeenCalledWith(
'general-purpose',
);
expect(AgentHeadless.create).not.toHaveBeenCalled();
});

it('falls back to general-purpose when non-interactive (even with fork flag on)', async () => {
vi.mocked(
config.isInteractive as ReturnType<typeof vi.fn>,
).mockReturnValue(false);
// Fork flag is on, but non-interactive → isForkSubagentEnabled() returns false
// because it requires both flag + interactive.

const mockLoadedSubagent: SubagentConfig = {
name: 'general-purpose',
description: 'General-purpose agent',
systemPrompt: 'You are a general-purpose agent.',
level: 'builtin',
filePath: '<builtin:general-purpose>',
};
vi.mocked(mockSubagentManager.loadSubagent).mockResolvedValue(
mockLoadedSubagent,
);

const params: AgentParams = {
description: 'fork task',
prompt: 'do the thing',
};

const invocation = (
agentTool as AgentToolWithProtectedMethods
).createInvocation(params);
await invocation.execute();

// Should fall back to general-purpose
expect(mockSubagentManager.loadSubagent).toHaveBeenCalledWith(
'general-purpose',
);
expect(AgentHeadless.create).not.toHaveBeenCalled();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The "rejects fork in non-interactive mode" test verifies llmContent and that AgentHeadless.create was not called, but does not verify the returnDisplay shape. Other tests in this file (e.g., the subagent-not-found test) verify returnDisplay.status, returnDisplay.subagentName, and returnDisplay.terminateReason. This gap means a regression in the UI-facing error display would go undetected.

Suggested change
expect(AgentHeadless.create).not.toHaveBeenCalled();
const llmText = partToString(result.llmContent);
expect(llmText).toContain('not available in non-interactive mode');
expect(AgentHeadless.create).not.toHaveBeenCalled();
const display = result.returnDisplay as {
status: string;
subagentName: string;
terminateReason: string;
};
expect(display.status).toBe('failed');
expect(display.subagentName).toBe('fork');
expect(display.terminateReason).toBe(
'Fork subagent is not available in non-interactive mode',
);

— qwen3.7-max via Qwen Code /review

});

it('should call AgentHeadless.create directly and execute without options', async () => {
Expand Down Expand Up @@ -2534,6 +2671,13 @@ describe('AgentTool', () => {
});

it('persists fork capability snapshots in the bootstrap transcript', async () => {
// Fork requires opt-in + interactive
(config as unknown as Record<string, unknown>)['isForkSubagentEnabled'] =
vi.fn().mockReturnValue(true);
(config as unknown as Record<string, unknown>)['isInteractive'] = vi
.fn()
.mockReturnValue(true);

const forkParams: AgentParams = {
description: 'Fork task',
prompt: 'Investigate issue',
Expand Down
50 changes: 41 additions & 9 deletions packages/core/src/tools/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
buildChildMessage,
buildWorktreeNotice,
isInForkExecution,
isForkSubagentEnabled,
runInForkContext,
} from './fork-subagent.js';
import {
Expand All @@ -66,7 +67,10 @@ import type {
AgentApprovalRequestEvent,
AgentUsageEvent,
} from '../../agents/runtime/agent-events.js';
import { BuiltinAgentRegistry } from '../../subagents/builtin-agents.js';
import {
BuiltinAgentRegistry,
DEFAULT_BUILTIN_SUBAGENT_TYPE,
} from '../../subagents/builtin-agents.js';
import { createDebugLogger } from '../../utils/debugLogger.js';
import { PermissionMode } from '../../hooks/types.js';
import type { StopHookOutput } from '../../hooks/types.js';
Expand Down Expand Up @@ -527,7 +531,11 @@ The Agent tool launches specialized agents (subprocesses) that autonomously hand
Available agent types and the tools they have access to:
${subagentDescriptions}

When using the Agent tool, specify a subagent_type parameter to select which agent type to use.
${
isForkSubagentEnabled(this.config)
? `When using the Agent tool, specify a subagent_type to use a specialized agent, or omit it to fork yourself — a fork inherits your full conversation context.`
: `When using the Agent tool, specify a subagent_type parameter to select which agent type to use. If omitted, the general-purpose agent is used.`
}

When NOT to use the Agent tool:
- If you want to read a specific file path, use the ${ToolNames.READ_FILE} tool or the ${ToolNames.GLOB} tool instead of the ${ToolNames.AGENT} tool, to find the match more quickly
Expand All @@ -547,17 +555,35 @@ Usage notes:
- If the user specifies that they want you to run agents "in parallel", you MUST send a single message with multiple Agent tool use content blocks. For example, if you need to launch both a build-validator agent and a test-runner agent in parallel, send a single message with both tool calls.
- You can optionally set \`run_in_background: true\` to run the agent in the background. You will be notified when it completes. Use this when you have genuinely independent work to do in parallel and don't need the agent's results before you can proceed.
- You can optionally set \`isolation: "worktree"\` to run the agent in a temporary git worktree, giving it an isolated copy of the repository. The worktree is automatically cleaned up if the agent makes no changes; if changes are made, the worktree path and branch are returned in the result so you can review or merge them.
${
isForkSubagentEnabled(this.config)
? `
## When to fork

Fork yourself (omit \`subagent_type\`) when the intermediate tool output isn't worth keeping in your context. The criterion is qualitative — "will I need this output again" — not task size.
- **Research**: fork open-ended questions. If research can be broken into independent questions, launch parallel forks in one message. A fork beats a fresh subagent for this — it inherits context and shares your cache.
- **Implementation**: prefer to fork implementation work that requires more than a couple of edits. Do research before jumping to implementation.

Forks are cheap because they share your prompt cache. Don't set \`model\` on a fork — a different model can't reuse the parent's cache. Pass a short \`name\` (one or two words, lowercase) so the user can track the fork.

**Don't peek.** The tool result includes an output — do not read or tail it unless the user explicitly asks for a progress check. You get a completion notification; trust it. Reading the transcript mid-flight pulls the fork's tool noise into your context, which defeats the point of forking.

**Don't race.** After launching, you know nothing about what the fork found. Never fabricate or predict fork results in any format — not as prose, summary, or structured output. The notification arrives as a user-role message in a later turn; it is never something you write yourself. If the user asks a follow-up before the notification lands, tell them the fork is still running — give status, not a guess.

**Writing a fork prompt.** Since the fork inherits your context, the prompt is a *directive* — what to do, not what the situation is. Be specific about scope: what's in, what's out, what another agent is handling. Don't re-explain background.
`
: ''
}
## Writing the prompt

Brief the agent like a smart colleague who just walked into the room — it has not seen this conversation, does not know what you've tried, and does not understand why this task matters.
${isForkSubagentEnabled(this.config) ? 'When spawning a fresh agent (with a `subagent_type`), it starts with zero context. ' : ''}Brief the agent like a smart colleague who just walked into the room — it has not seen this conversation, does not know what you've tried, and does not understand why this task matters.
- Explain what you're trying to accomplish and why.
- Describe what you've already learned or ruled out.
- Give enough context about the surrounding problem that the agent can make judgment calls rather than just following a narrow instruction.
- If you need a short response, say so explicitly.
- For lookups, provide the exact target. For investigations, provide the actual question rather than an over-prescribed sequence of steps.

Terse command-style prompts produce shallow, generic work.
${isForkSubagentEnabled(this.config) ? 'For fresh agents, terse' : 'Terse'} command-style prompts produce shallow, generic work.

**Never delegate understanding.** Do not write prompts like "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood the task: include relevant file paths, constraints, what specifically needs to be learned or changed, and what is out of scope.

Expand Down Expand Up @@ -1400,7 +1426,13 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
let restoreParentPM: () => void = () => {};

try {
const isFork = !this.params.subagent_type;
// When subagent_type is omitted and fork is enabled (opt-in +
// interactive), use fork. Otherwise fall back to general-purpose.
const isFork =
!this.params.subagent_type && isForkSubagentEnabled(this.config);
const effectiveSubagentType =
this.params.subagent_type ??
(isFork ? undefined : DEFAULT_BUILTIN_SUBAGENT_TYPE);
let subagentConfig: SubagentConfig;

if (isFork) {
Expand All @@ -1426,18 +1458,18 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
}
} else {
const loadedConfig = await this.subagentManager.loadSubagent(
this.params.subagent_type!,
effectiveSubagentType!,
);
if (!loadedConfig) {
return {
llmContent: `Subagent "${this.params.subagent_type}" not found`,
llmContent: `Subagent "${effectiveSubagentType}" not found`,
returnDisplay: {
type: 'task_execution' as const,
subagentName: this.params.subagent_type!,
subagentName: effectiveSubagentType!,
taskDescription: this.params.description,
taskPrompt: this.params.prompt,
status: 'failed' as const,
terminateReason: `Subagent "${this.params.subagent_type}" not found`,
terminateReason: `Subagent "${effectiveSubagentType}" not found`,
},
};
}
Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/tools/agent/fork-subagent.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,26 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import type { Content } from '@google/genai';
import type { Config } from '../../config/config.js';

export const FORK_SUBAGENT_TYPE = 'fork';

/**
* Fork subagent feature gate.
*
* Fork requires two conditions:
* 1. Explicit opt-in via QWEN_CODE_ENABLE_FORK_SUBAGENT=1 env var
* or programmatic `forkSubagentEnabled: true` (defaults to off).
* 2. An interactive session — non-interactive sessions (e.g. `qwen -p`,
* SDK headless, CI/CD) lack a terminal UI for fork progress display
* and permission bubble-up, which can cause hangs or silent failures.
*
* When fork is disabled, omitting `subagent_type` falls back to a
* general-purpose subagent instead of forking.
*/
export function isForkSubagentEnabled(config: Config): boolean {
return config.isForkSubagentEnabled() && config.isInteractive();
}

export const FORK_BOILERPLATE_TAG = 'fork-boilerplate';
export const FORK_DIRECTIVE_PREFIX = 'Directive: ';

Expand Down
Loading