diff --git a/docs/design/fork-subagent/fork-subagent-design.md b/docs/design/fork-subagent/fork-subagent-design.md new file mode 100644 index 0000000000..b17af456db --- /dev/null +++ b/docs/design/fork-subagent/fork-subagent-design.md @@ -0,0 +1,112 @@ +# Fork Subagent Design + +> Implicit fork subagent that inherits the parent's full conversation context and shares prompt cache for cost-efficient parallel task execution. + +## Overview + +When the Agent tool is called without `subagent_type`, it triggers an implicit **fork** — a background subagent that inherits the parent's conversation history, system prompt, and tool definitions. The fork uses `CacheSafeParams` to ensure its API requests share the same prefix as the parent's, enabling DashScope prompt cache hits. + +## Architecture + +``` +Parent conversation: [SystemPrompt | Tools | Msg1 | Msg2 | ... | MsgN (model)] + ↑ identical prefix for all forks ↑ + +Fork A: [...MsgN | placeholder results | "Research A"] ← shared cache +Fork B: [...MsgN | placeholder results | "Modify B"] ← shared cache +Fork C: [...MsgN | placeholder results | "Test C"] ← shared cache +``` + +## Key Components + +### 1. FORK_AGENT (`forkSubagent.ts`) + +Synthetic agent config, not registered in `builtInAgents`. Has a fallback `systemPrompt` but in practice uses the parent's rendered system prompt via `generationConfigOverride`. + +### 2. CacheSafeParams Integration (`agent.ts` + `forkedQuery.ts`) + +``` +agent.ts (fork path) + │ + ├── getCacheSafeParams() ← parent's generationConfig snapshot + │ ├── generationConfig ← systemInstruction + tools + temp/topP + │ └── history ← (not used — we build extraHistory instead) + │ + ├── forkGenerationConfig ← passed as generationConfigOverride + └── forkToolsOverride ← FunctionDeclaration[] extracted from tools + │ + ▼ + AgentHeadless.execute(context, signal, { + extraHistory, ← parent conversation history + generationConfigOverride, ← parent's exact systemInstruction + tools + toolsOverride, ← parent's exact tool declarations + }) + │ + ▼ + AgentCore.createChat(context, { + extraHistory, + generationConfigOverride, ← bypasses buildChatSystemPrompt() + }) AND skips getInitialChatHistory() + │ (extraHistory already has env context) + ▼ + new GeminiChat(config, generationConfig, startHistory) + ↑ byte-identical to parent's config +``` + +### 3. History Construction (`agent.ts` + `forkSubagent.ts`) + +The fork's `extraHistory` must end with a model message to maintain Gemini API's user/model alternation when `agent-headless` sends the `task_prompt`. + +Three cases: + +| Parent history ends with | extraHistory construction | task_prompt | +| ----------------------------- | ---------------------------------------------------------------------- | ------------------------------ | +| `model` (no function calls) | `[...rawHistory]` (unchanged) | `buildChildMessage(directive)` | +| `model` (with function calls) | `[...rawHistory, model(clone), user(responses+directive), model(ack)]` | `'Begin.'` | +| `user` (unusual) | `rawHistory.slice(0, -1)` (drop trailing user) | `buildChildMessage(directive)` | + +### 4. Recursive Fork Prevention (`forkSubagent.ts`) + +`isInForkChild()` scans conversation history for the `` tag. If found, the fork attempt is rejected with an error message. + +### 5. Background Execution (`agent.ts`) + +Fork uses `void executeSubagent()` (fire-and-forget) and returns `FORK_PLACEHOLDER_RESULT` immediately to the parent. Errors in the background task are caught, logged, and reflected in the display state. + +## Data Flow + +``` +1. Model calls Agent tool (no subagent_type) +2. agent.ts: import forkSubagent.js +3. agent.ts: getCacheSafeParams() → forkGenerationConfig + forkToolsOverride +4. agent.ts: build extraHistory from parent's getHistory(true) +5. agent.ts: build forkTaskPrompt (directive or 'Begin.') +6. agent.ts: createAgentHeadless(FORK_AGENT, ...) +7. agent.ts: void executeSubagent() — background +8. agent.ts: return FORK_PLACEHOLDER_RESULT to parent immediately +9. Background: + a. AgentHeadless.execute(context, signal, {extraHistory, generationConfigOverride, toolsOverride}) + b. AgentCore.createChat() — uses parent's generationConfig (cache-shared) + c. runReasoningLoop() — uses parent's tool declarations + d. Fork executes tools, produces result + e. updateDisplay() with final status +``` + +## Graceful Degradation + +If `getCacheSafeParams()` returns null (first turn, no history yet), the fork falls back to: + +- `FORK_AGENT.systemPrompt` for system instruction +- `prepareTools()` for tool declarations + +This ensures the fork always works, even without cache sharing. + +## Files + +| File | Role | +| ---------------------------------------------------- | ------------------------------------------------------------------------------------- | +| `packages/core/src/agents/runtime/forkSubagent.ts` | FORK_AGENT config, buildForkedMessages(), isInForkChild(), buildChildMessage() | +| `packages/core/src/tools/agent.ts` | Fork path: CacheSafeParams retrieval, extraHistory construction, background execution | +| `packages/core/src/agents/runtime/agent-headless.ts` | execute() options: generationConfigOverride, toolsOverride | +| `packages/core/src/agents/runtime/agent-core.ts` | CreateChatOptions.generationConfigOverride | +| `packages/core/src/followup/forkedQuery.ts` | CacheSafeParams infrastructure (existing, no changes) | diff --git a/docs/users/features/sub-agents.md b/docs/users/features/sub-agents.md index 28ce3e2673..9ac5a6fd8c 100644 --- a/docs/users/features/sub-agents.md +++ b/docs/users/features/sub-agents.md @@ -12,10 +12,46 @@ Subagents are independent AI assistants that: - **Work autonomously** - Once given a task, they work independently until completion or failure - **Provide detailed feedback** - You can see their progress, tool usage, and execution statistics in real-time +## Fork Subagent (Implicit Fork) + +In addition to named subagents, Qwen Code supports **implicit forking** — when the AI omits the `subagent_type` parameter, it triggers a fork that inherits the parent's full conversation context. + +### How Fork Differs from Named Subagents + +| | Named Subagent | Fork Subagent | +| ------------- | --------------------------------- | ----------------------------------------------------- | +| Context | Starts fresh, no parent history | Inherits parent's full conversation history | +| System prompt | Uses its own configured prompt | Uses parent's exact system prompt (for cache sharing) | +| Execution | Blocks the parent until done | Runs in background, parent continues immediately | +| Use case | Specialized tasks (testing, docs) | Parallel tasks that need the current context | + +### When Fork is Used + +The AI automatically uses fork when it needs to: + +- Run multiple research tasks in parallel (e.g., "investigate module A, B, and C") +- Perform background work while continuing the main conversation +- Delegate tasks that require understanding of the current conversation context + +### Prompt Cache Sharing + +All forks share the parent's exact API request prefix (system prompt, tools, conversation history), enabling DashScope prompt cache hits. When 3 forks run in parallel, the shared prefix is cached once and reused — saving 80%+ token costs compared to independent subagents. + +### Recursive Fork Prevention + +Fork children cannot create further forks. This is enforced at runtime — if a fork attempts to spawn another fork, it receives an error instructing it to execute tasks directly. + +### Current Limitations + +- **No result feedback**: Fork results are reflected in the UI progress display but are not automatically fed back into the main conversation. The parent AI sees a placeholder message and cannot act on the fork's output. +- **No worktree isolation**: Forks share the parent's working directory. Concurrent file modifications from multiple forks may conflict. + ## Key Benefits - **Task Specialization**: Create agents optimized for specific workflows (testing, documentation, refactoring, etc.) - **Context Isolation**: Keep specialized work separate from your main conversation +- **Context Inheritance**: Fork subagents inherit the full conversation for context-heavy parallel tasks +- **Prompt Cache Sharing**: Fork subagents share the parent's cache prefix, reducing token costs - **Reusability**: Save and reuse agent configurations across projects and sessions - **Controlled Access**: Limit which tools each agent can use for security and focus - **Progress Visibility**: Monitor agent execution with real-time progress updates @@ -23,7 +59,7 @@ Subagents are independent AI assistants that: ## How Subagents Work 1. **Configuration**: You create Subagents configurations that define their behavior, tools, and system prompts -2. **Delegation**: The main AI can automatically delegate tasks to appropriate Subagents +2. **Delegation**: The main AI can automatically delegate tasks to appropriate Subagents — or implicitly fork when no specific subagent type is needed 3. **Execution**: Subagents work independently, using their configured tools to complete tasks 4. **Results**: They return results and execution summaries back to the main conversation diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index fa5e753be1..54589b2204 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -101,6 +101,15 @@ export interface CreateChatOptions { * conversational context (e.g., from the main session that spawned it). */ 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; + }; } /** @@ -222,7 +231,12 @@ export class AgentCore { ); } - const envHistory = await getInitialChatHistory(this.runtimeContext); + // When generationConfigOverride is provided (fork path), the extraHistory + // already contains the parent's env context. Skip getInitialChatHistory + // to avoid duplicating the env messages and breaking cache prefix match. + const envHistory = options?.generationConfigOverride + ? [] + : await getInitialChatHistory(this.runtimeContext); const startHistory = [ ...envHistory, @@ -230,22 +244,30 @@ export class AgentCore { ...(this.promptConfig.initialMessages ?? []), ]; - const systemInstruction = this.promptConfig.systemPrompt - ? this.buildChatSystemPrompt(context, options) - : undefined; + // 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 & { + systemInstruction?: string | Content; + }; - try { - 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 (systemInstruction) { generationConfig.systemInstruction = systemInstruction; } + } + try { return new GeminiChat( this.runtimeContext, generationConfig, diff --git a/packages/core/src/agents/runtime/agent-headless.ts b/packages/core/src/agents/runtime/agent-headless.ts index ac02f80dfb..8e78cab61c 100644 --- a/packages/core/src/agents/runtime/agent-headless.ts +++ b/packages/core/src/agents/runtime/agent-headless.ts @@ -192,8 +192,18 @@ 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; + }, ): Promise { - const chat = await this.core.createChat(context); + const chat = await this.core.createChat(context, { + extraHistory: options?.extraHistory, + generationConfigOverride: options?.generationConfigOverride, + }); if (!chat) { this.terminateMode = AgentTerminateMode.ERROR; @@ -212,7 +222,7 @@ export class AgentHeadless { abortController.abort(); } - const toolsList = this.core.prepareTools(); + const toolsList = options?.toolsOverride ?? this.core.prepareTools(); const initialTaskText = String( (context.get('task_prompt') as string) ?? 'Get Started!', diff --git a/packages/core/src/agents/runtime/forkSubagent.ts b/packages/core/src/agents/runtime/forkSubagent.ts new file mode 100644 index 0000000000..8df7dea385 --- /dev/null +++ b/packages/core/src/agents/runtime/forkSubagent.ts @@ -0,0 +1,116 @@ +import type { Content } from '@google/genai'; + +export const FORK_SUBAGENT_TYPE = 'fork'; + +export const FORK_BOILERPLATE_TAG = 'fork-boilerplate'; +export const FORK_DIRECTIVE_PREFIX = 'Directive: '; + +export const FORK_AGENT = { + name: FORK_SUBAGENT_TYPE, + description: + 'Implicit fork — inherits full conversation context. Not selectable via subagent_type; triggered by omitting subagent_type.', + tools: ['*'], + systemPrompt: + 'You are a forked worker process. Follow the directive in the conversation history. Execute tasks directly using available tools. Do not spawn sub-agents.', + 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}>`), + ); + }); +} + +export const FORK_PLACEHOLDER_RESULT = + 'Fork started — processing in background'; + +/** + * Build extra history messages for a forked subagent. + * + * When the last model message has function calls, we must include matching + * function responses in a user message (Gemini API requirement). The + * directive is embedded in this same user message to avoid consecutive + * user messages. + * + * When there are no function calls, we return [] — the parent history + * already ends with a model text message and the directive will be sent + * as the task_prompt by agent-headless (model → user alternation is OK). + * + * @param directive - The fork directive text (user's prompt) + * @param assistantMessage - The last model message from the parent history + * @returns Extra messages to append to history (may be empty) + */ +export function buildForkedMessages( + directive: string, + assistantMessage: Content, +): Content[] { + const toolUseParts = + assistantMessage.parts?.filter((part) => part.functionCall) || []; + + if (toolUseParts.length === 0) { + // No function calls — no extra messages needed. + // The parent history already ends with this model message. + return []; + } + + // Clone the assistant message to avoid mutating the original + const fullAssistantMessage: Content = { + role: assistantMessage.role, + parts: [...(assistantMessage.parts || [])], + }; + + // Build tool_result blocks for every tool_use, all with identical placeholder text. + // Include the directive text in the same user message to maintain + // proper user/model alternation. + const toolResultParts = toolUseParts.map((part) => ({ + functionResponse: { + id: part.functionCall!.id, + name: part.functionCall!.name, + response: { output: FORK_PLACEHOLDER_RESULT }, + }, + })); + + const toolResultMessage: Content = { + role: 'user', + parts: [ + ...toolResultParts, + { + text: buildChildMessage(directive), + }, + ], + }; + + return [fullAssistantMessage, toolResultMessage]; +} + +export function buildChildMessage(directive: string): string { + return `<${FORK_BOILERPLATE_TAG}> +STOP. READ THIS FIRST. + +You are a forked worker process. You are NOT the main agent. + +RULES (non-negotiable): +1. You ARE the fork. Do NOT spawn sub-agents; execute directly. +2. Do NOT converse, ask questions, or suggest next steps +3. Do NOT editorialize or add meta-commentary +4. USE your tools directly: Bash, Read, Write, etc. +5. If you modify files, commit your changes before reporting. Include the commit hash in your report. +6. Do NOT emit text between tool calls. Use tools silently, then report once at the end. +7. Stay strictly within your directive's scope. If you discover related systems outside your scope, mention them in one sentence at most — other workers cover those areas. +8. Keep your report under 500 words unless the directive specifies otherwise. Be factual and concise. +9. Your response MUST begin with "Scope:". No preamble, no thinking-out-loud. +10. REPORT structured facts, then stop + +Output format (plain text labels, not markdown headers): + Scope: + Result: + Key files: + Files changed: + Issues: + + +${FORK_DIRECTIVE_PREFIX}${directive}`; +} diff --git a/packages/core/src/tools/agent.test.ts b/packages/core/src/tools/agent.test.ts index 505b434a04..d6c2ce76fd 100644 --- a/packages/core/src/tools/agent.test.ts +++ b/packages/core/src/tools/agent.test.ts @@ -398,6 +398,7 @@ describe('AgentTool', () => { expect(mockAgent.execute).toHaveBeenCalledWith( mockContextState, undefined, // signal parameter (undefined when not provided) + { extraHistory: undefined }, // extraHistory ); const llmText = partToString(result.llmContent); diff --git a/packages/core/src/tools/agent.ts b/packages/core/src/tools/agent.ts index c8599badc8..a5a2041242 100644 --- a/packages/core/src/tools/agent.ts +++ b/packages/core/src/tools/agent.ts @@ -40,7 +40,7 @@ import type { StopHookOutput } from '../hooks/types.js'; export interface AgentParams { description: string; prompt: string; - subagent_type: string; + subagent_type?: string; } const debugLogger = createDebugLogger('AGENT'); @@ -76,7 +76,7 @@ export class AgentTool extends BaseDeclarativeTool { description: 'The type of specialized agent to use for this task', }, }, - required: ['description', 'prompt', 'subagent_type'], + required: ['description', 'prompt'], additionalProperties: false, $schema: 'http://json-schema.org/draft-07/schema#', }; @@ -239,23 +239,23 @@ assistant: "I'm going to use the ${ToolNames.AGENT} tool to launch the greeting- return 'Parameter "prompt" must be a non-empty string.'; } - if ( - !params.subagent_type || - typeof params.subagent_type !== 'string' || - params.subagent_type.trim() === '' - ) { - return 'Parameter "subagent_type" must be a non-empty string.'; - } - - // Validate that the subagent exists (case-insensitive) - const lowerType = params.subagent_type.toLowerCase(); - const subagentExists = this.availableSubagents.some( - (subagent) => subagent.name.toLowerCase() === lowerType, - ); + if (params.subagent_type !== undefined) { + if ( + typeof params.subagent_type !== 'string' || + params.subagent_type.trim() === '' + ) { + return 'Parameter "subagent_type" must be a non-empty string.'; + } + // Validate that the subagent exists (case-insensitive) + const lowerType = params.subagent_type.toLowerCase(); + const subagentExists = this.availableSubagents.some( + (subagent) => subagent.name.toLowerCase() === lowerType, + ); - if (!subagentExists) { - const availableNames = this.availableSubagents.map((s) => s.name); - return `Subagent "${params.subagent_type}" not found. Available subagents: ${availableNames.join(', ')}`; + if (!subagentExists) { + const availableNames = this.availableSubagents.map((s) => s.name); + return `Subagent "${params.subagent_type}" not found. Available subagents: ${availableNames.join(', ')}`; + } } return null; @@ -483,25 +483,134 @@ class AgentToolInvocation extends BaseToolInvocation { updateOutput?: (output: ToolResultDisplay) => void, ): Promise { try { - // Load the subagent configuration - const subagentConfig = await this.subagentManager.loadSubagent( - this.params.subagent_type, - ); + let subagentConfig: SubagentConfig; + let extraHistory: Array | undefined; + let forkPlaceholderResult: string | undefined; + let forkTaskPrompt: string | undefined; + let forkGenerationConfig: + | import('@google/genai').GenerateContentConfig + | undefined; + let forkToolsOverride: + | Array + | undefined; + + if (!this.params.subagent_type) { + const { + FORK_AGENT, + FORK_PLACEHOLDER_RESULT, + buildForkedMessages, + buildChildMessage, + isInForkChild, + } = await import('../agents/runtime/forkSubagent.js'); + forkPlaceholderResult = FORK_PLACEHOLDER_RESULT; + 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( + '../followup/forkedQuery.js' + ); + const cacheSafeParams = getCacheSafeParams(); + if (cacheSafeParams) { + forkGenerationConfig = cacheSafeParams.generationConfig; + const tools = cacheSafeParams.generationConfig.tools; + if (tools && tools.length > 0) { + forkToolsOverride = tools.flatMap( + (t) => + ( + t as { + functionDeclarations?: Array; + } + ).functionDeclarations ?? [], + ); + } + } - if (!subagentConfig) { - 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`, - }; + const geminiClient = this.config.getGeminiClient(); + if (geminiClient) { + const rawHistory = geminiClient.getHistory(true); + + 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', + }; + + return { + llmContent: + 'Error: Cannot create a fork from within an existing fork child. Please execute tasks directly.', + returnDisplay: errorDisplay, + }; + } - return { - llmContent: `Subagent "${this.params.subagent_type}" not found`, - 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, + ); + + 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, + }; + } + subagentConfig = loadedConfig; } // Initialize the current display state @@ -528,134 +637,179 @@ class AgentToolInvocation extends BaseToolInvocation { ); // 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', this.params.prompt); + 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; + const agentType = this.params.subagent_type || subagentConfig.name; - if (hookSystem) { + const executeSubagent = async () => { try { - const startHookOutput = await hookSystem.fireSubagentStartEvent( - agentId, - agentType, - PermissionMode.Default, - signal, - ); + if (hookSystem) { + try { + const startHookOutput = await hookSystem.fireSubagentStartEvent( + agentId, + agentType, + PermissionMode.Default, + signal, + ); - // Inject additional context from hook output into subagent context - const additionalContext = startHookOutput?.getAdditionalContext(); - if (additionalContext) { - contextState.set('hook_context', additionalContext); + // 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}`, + ); + } } - } 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; - } + // Execute the subagent (blocking) + await subagent.execute(contextState, signal, { + extraHistory, + generationConfigOverride: forkGenerationConfig, + toolsOverride: forkToolsOverride, + }); - try { - const stopHookOutput = await hookSystem.fireSubagentStopEvent( - agentId, - agentType, - transcriptPath, - subagent.getFinalText(), - stopHookActive, - PermissionMode.Default, - signal, - ); + // Fire SubagentStop hook after execution and handle block decisions + if (hookSystem && !signal?.aborted) { + const transcriptPath = this.config.getTranscriptPath(); + let stopHookActive = false; - const typedStopOutput = stopHookOutput as - | StopHookOutput - | undefined; + // Loop to handle "block" decisions (prevent subagent from stopping) + let continueExecution = true; + let iterationCount = 0; + const maxIterations = 5; // Prevent infinite loops from hook misconfigurations - if ( - typedStopOutput?.isBlockingDecision() || - typedStopOutput?.shouldStopExecution() - ) { - // Feed the reason back to the subagent and continue execution - const continueReason = typedStopOutput.getEffectiveReason(); - stopHookActive = true; + while (continueExecution) { + iterationCount++; - const continueContext = new ContextState(); - continueContext.set('task_prompt', continueReason); - await subagent.execute(continueContext, signal); + // 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 (signal?.aborted) { + try { + const stopHookOutput = await hookSystem.fireSubagentStopEvent( + agentId, + agentType, + transcriptPath, + subagent.getFinalText(), + stopHookActive, + PermissionMode.Default, + 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, + }); + + 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; } - // Loop continues to re-check SubagentStop hook - } else { - continueExecution = false; } - } catch (hookError) { - debugLogger.warn( - `[TaskTool] SubagentStop hook failed, allowing stop: ${hookError}`, + } + + // 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, ); - continueExecution = false; } + } 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, + ); } - } - - // 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, - ); + if (!this.params.subagent_type) { + // Background fork execution + void executeSubagent(); + return { + llmContent: [{ text: forkPlaceholderResult! }], + returnDisplay: this.currentDisplay!, + }; } else { - this.updateDisplay( - { - status: success ? 'completed' : 'failed', - terminateReason: terminateMode, - result: finalText, - executionSummary, - }, - updateOutput, - ); + await executeSubagent(); + const finalText = subagent.getFinalText(); + const terminateMode = subagent.getTerminateMode(); + if (terminateMode === AgentTerminateMode.ERROR) { + return { + llmContent: finalText || 'Subagent execution failed.', + returnDisplay: this.currentDisplay!, + }; + } + return { + llmContent: [{ text: finalText }], + returnDisplay: this.currentDisplay!, + }; } - - return { - llmContent: [{ text: finalText }], - returnDisplay: this.currentDisplay!, - }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/packages/core/src/tools/edit.test.ts b/packages/core/src/tools/edit.test.ts index 71ff122094..5c385ce187 100644 --- a/packages/core/src/tools/edit.test.ts +++ b/packages/core/src/tools/edit.test.ts @@ -784,20 +784,23 @@ describe('EditTool', () => { expect(() => tool.build(params)).toThrow(); }); - it('should return FILE_WRITE_FAILURE on write error', async () => { - fs.writeFileSync(filePath, 'content', 'utf8'); - // Make file readonly to trigger a write error - fs.chmodSync(filePath, '444'); - - const params: EditToolParams = { - file_path: filePath, - old_string: 'content', - new_string: 'new content', - }; - const invocation = tool.build(params); - const result = await invocation.execute(new AbortController().signal); - expect(result.error?.type).toBe(ToolErrorType.FILE_WRITE_FAILURE); - }); + it.skipIf(process.getuid && process.getuid() === 0)( + 'should return FILE_WRITE_FAILURE on write error', + async () => { + fs.writeFileSync(filePath, 'content', 'utf8'); + // Make file readonly to trigger a write error + fs.chmodSync(filePath, '444'); + + const params: EditToolParams = { + file_path: filePath, + old_string: 'content', + new_string: 'new content', + }; + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); + expect(result.error?.type).toBe(ToolErrorType.FILE_WRITE_FAILURE); + }, + ); }); describe('getDescription', () => { diff --git a/packages/core/src/utils/pathReader.test.ts b/packages/core/src/utils/pathReader.test.ts index 97717d0a31..346c6a7867 100644 --- a/packages/core/src/utils/pathReader.test.ts +++ b/packages/core/src/utils/pathReader.test.ts @@ -368,8 +368,10 @@ describe('readPathFromWorkspace', () => { ).rejects.toThrow('Path not found in workspace: not-found.txt'); }); - // mock-fs permission simulation is unreliable on Windows. - it.skipIf(process.platform === 'win32')( + // mock-fs permission simulation is unreliable on Windows and when running as root. + it.skipIf( + process.platform === 'win32' || (process.getuid && process.getuid() === 0), + )( 'should return an error string if reading a file with no permissions', async () => { mock({