diff --git a/.qwen/skills/structured-debugging/SKILL.md b/.qwen/skills/structured-debugging/SKILL.md index 99ea52903..da19174cf 100644 --- a/.qwen/skills/structured-debugging/SKILL.md +++ b/.qwen/skills/structured-debugging/SKILL.md @@ -1,6 +1,6 @@ --- name: structured-debugging -description: +description: > Hypothesis-driven debugging methodology for hard bugs. Use this skill whenever you're investigating non-trivial bugs, unexpected behavior, flaky tests, or tracing issues through complex systems. Activate proactively when debugging @@ -32,11 +32,9 @@ Good: "The leader hangs because `hasActiveTeammates()` returns true after all ag have reported completed, likely because terminal status isn't being set on the agent object after the backend process exits." -Create a side note file for the investigation: - -``` -~/.qwen/investigations/-.md -``` +For bugs you expect to take more than one round, create a side note file +for the investigation in whichever location the project uses for such +notes. Write your hypothesis there. This file persists across conversation turns and even across sessions — it's your investigation journal. @@ -49,6 +47,12 @@ confirm or reject your hypothesis. Think about what data you need to see. Don't scatter `console.log` everywhere. Identify the 2-3 places where your hypothesis makes a testable prediction, and instrument those. +Prefer logging _values_ (return codes, payload contents, stream types, +message bodies, env state) over _presence checks_ ("was this function +called?", "was this branch taken?"). Code-path traces tell you what ran; +data traces tell you what it ran on. Most non-trivial bugs are correct +code processing wrong data. + Ask yourself: "If my hypothesis is correct, what will I see at point X? If it's wrong, what will I see instead?" @@ -129,6 +133,23 @@ is still broken if the inbox contains stale messages from a previous run. Always inspect the _content_ flowing through the code, not just whether the code runs. Check payloads, message contents, file data, and database state. +### Reframing the user's report instead of investigating it + +When the user reports a symptom your own run doesn't reproduce, the +contradiction _is_ the evidence — the two environments differ in some way +you haven't identified yet. The wrong move is to reframe their report +("they must be on a stale SHA", "they must be confused about what they +saw", "must be a flake") so that your run becomes the ground truth. Once +you do that, every later piece of evidence gets bent to defend the +reframing, and the actual bug stays hidden. + +The right move: catalogue what differs between their environment and +yours (TTY vs pipe, terminal emulator, shell, locale, env vars, prior +state, build artifacts) before forming any hypothesis. For ambiguous +symptoms ("no output", "it's slow", "it's wrong") ask one disambiguating +question first — e.g., "does it hang or exit cleanly?" — that prunes the +hypothesis space cheaply before any test run. + ### Losing context across attempts After several debugging rounds, you start forgetting what you already tried and @@ -164,3 +185,10 @@ Fix: [what you're changing and why it addresses the root cause] ``` Then apply the fix, remove instrumentation, and verify with a clean run. + +## Worked examples + +- [`examples/headless-bg-agent-empty-stdout.md`](examples/headless-bg-agent-empty-stdout.md) + — pipe-captured runs all passed; the user's TTY printed nothing. The + contradiction _was_ the bug. Illustrates _reproduction contradiction is + data_ and _instrument data, not code paths_. diff --git a/.qwen/skills/structured-debugging/examples/headless-bg-agent-empty-stdout.md b/.qwen/skills/structured-debugging/examples/headless-bg-agent-empty-stdout.md new file mode 100644 index 000000000..33a356b76 --- /dev/null +++ b/.qwen/skills/structured-debugging/examples/headless-bg-agent-empty-stdout.md @@ -0,0 +1,59 @@ +# Worked example: headless run prints empty stdout in zsh TTY + +A short qwen-code case to illustrate two failure modes from `SKILL.md`: +_reproduction contradiction is data_, and _instrument the data flow, not +just the code path_. + +## The bug + +User: `npm run dev -- -p "..."` in zsh prints nothing. Process exits clean, +`~/.qwen/logs` shows the model returned proper text. Stdout was empty. + +Cause: `JsonOutputAdapter.emitResult` wrote `resultMessage.result` without +a trailing `\n`. zsh's `PROMPT_SP` (powerlevel10k, agnoster, …) detects +the missing newline and emits `\r\033[K` before drawing the next prompt, +erasing the line. Pipe-captured stdout has no `PROMPT_SP`, so the bug is +invisible there. + +Fix: append `\n` to the write. + +## What made the case instructive + +Every reproduction attempt from a debugging environment that captures +stdout (Cursor's Shell tool, `out=$(...)`, `tee`, file redirect) **passed**. +14/14 success against the user's 0/N. Same SHA, same machine, same +command. The only variable was: pipe stdout vs TTY stdout. + +That contradiction was the entire investigation. Once it was named, the +fix was one line. + +## Lessons mapped to SKILL.md + +- **Reproduction contradiction is data, not user error.** When your run + succeeds and the user's fails on identical state, the _difference + between the two environments_ is where the bug lives. Catalogue what + differs (TTY vs pipe, terminal emulator, shell, locale, env vars, + prior state) before forming any hypothesis. Reframing the user's + report ("they must be on stale code") burns rounds and credibility. + +- **Ask the one disambiguating question first.** "Does it hang or exit + cleanly?" would have falsified the most tempting wrong hypothesis here + (the recently-fixed drain-loop hang) on turn one. For any "no output" + report, that question is free and prunes half the hypothesis space. + +- **Instrument the data flow, not just the code path.** Tracing whether + `write` was called showed the happy path firing every time and resolved + nothing. The breakthrough was logging the _return value_ of + `process.stdout.write` together with `process.stdout.isTTY`. Code-path + traces tell you what ran; data traces tell you what it ran on. + +- **Pipe ≠ TTY.** A passing pipe-captured run does not prove a TTY user + sees the same output. Shell prompts can post-process trailing-newline- + less writes; terminals can swallow control sequences; pipes do + neither. When debugging interactive-shell symptoms, get evidence from + the user's actual terminal at least once. + +## Reference + +Fix commit: qwen-code `feadf052f` — +`fix(cli): append newline to text-mode emitResult so zsh PROMPT_SP doesn't erase the line` diff --git a/packages/cli/src/acp-integration/session/HistoryReplayer.ts b/packages/cli/src/acp-integration/session/HistoryReplayer.ts index d01948af6..2c3f5d1d2 100644 --- a/packages/cli/src/acp-integration/session/HistoryReplayer.ts +++ b/packages/cli/src/acp-integration/session/HistoryReplayer.ts @@ -4,7 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { ChatRecord, AgentResultDisplay } from '@qwen-code/qwen-code-core'; +import type { + ChatRecord, + AgentResultDisplay, + NotificationRecordPayload, +} from '@qwen-code/qwen-code-core'; import type { Content, GenerateContentResponseUsageMetadata, @@ -49,6 +53,21 @@ export class HistoryReplayer { this.setActiveRecordId(record.uuid, record.timestamp); switch (record.type) { case 'user': + // Notification/cron records hold raw XML/prompt the user never + // typed; replay the friendly displayText so the assistant's reply + // has an antecedent in the ACP transcript. + if (record.subtype === 'notification' || record.subtype === 'cron') { + const displayText = ( + record.systemPayload as NotificationRecordPayload | undefined + )?.displayText; + if (displayText) { + await this.messageEmitter.emitUserMessage( + displayText, + record.timestamp, + ); + } + break; + } if (record.message) { await this.replayContent(record.message, 'user', record.timestamp); } diff --git a/packages/cli/src/nonInteractive/io/JsonOutputAdapter.test.ts b/packages/cli/src/nonInteractive/io/JsonOutputAdapter.test.ts index ec8c598f5..53d9fda57 100644 --- a/packages/cli/src/nonInteractive/io/JsonOutputAdapter.test.ts +++ b/packages/cli/src/nonInteractive/io/JsonOutputAdapter.test.ts @@ -428,7 +428,7 @@ describe('JsonOutputAdapter', () => { expect(stdoutWriteSpy).toHaveBeenCalled(); const output = stdoutWriteSpy.mock.calls[0][0] as string; - expect(output).toBe('Response text'); + expect(output).toBe('Response text\n'); }); it('should emit error result to stderr in text mode', () => { @@ -447,7 +447,7 @@ describe('JsonOutputAdapter', () => { expect(stderrWriteSpy).toHaveBeenCalled(); const output = stderrWriteSpy.mock.calls[0][0] as string; - expect(output).toBe('Test error message'); + expect(output).toBe('Test error message\n'); stderrWriteSpy.mockRestore(); }); @@ -465,7 +465,7 @@ describe('JsonOutputAdapter', () => { expect(stdoutWriteSpy).toHaveBeenCalled(); const output = stdoutWriteSpy.mock.calls[0][0] as string; - expect(output).toBe('Custom summary text'); + expect(output).toBe('Custom summary text\n'); }); it('should handle empty error message in text mode', () => { @@ -484,7 +484,7 @@ describe('JsonOutputAdapter', () => { expect(stderrWriteSpy).toHaveBeenCalled(); const output = stderrWriteSpy.mock.calls[0][0] as string; // When no errorMessage is provided, the default 'Unknown error' is used - expect(output).toBe('Unknown error'); + expect(output).toBe('Unknown error\n'); stderrWriteSpy.mockRestore(); }); diff --git a/packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts index 68633675b..5d36ac7f0 100644 --- a/packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts +++ b/packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts @@ -67,9 +67,9 @@ export class JsonOutputAdapter if (this.config.getOutputFormat() === 'text') { if (resultMessage.is_error) { - process.stderr.write(`${resultMessage.error?.message || ''}`); + process.stderr.write(`${resultMessage.error?.message || ''}\n`); } else { - process.stdout.write(`${resultMessage.result}`); + process.stdout.write(`${resultMessage.result}\n`); } } else { // Emit the entire messages array as JSON (includes all main agent + subagent messages) diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 8bd34ca22..6fe474b07 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -146,6 +146,11 @@ describe('runNonInteractive', () => { isInteractive: vi.fn().mockReturnValue(false), isCronEnabled: vi.fn().mockReturnValue(false), getCronScheduler: vi.fn().mockReturnValue(null), + getBackgroundTaskRegistry: vi.fn().mockReturnValue({ + setNotificationCallback: vi.fn(), + setRegisterCallback: vi.fn(), + getRunning: vi.fn().mockReturnValue([]), + }), } as unknown as Config; mockSettings = { @@ -255,7 +260,7 @@ describe('runNonInteractive', () => { 'prompt-id-1', { type: SendMessageType.UserQuery }, ); - expect(processStdoutSpy).toHaveBeenCalledWith('Hello World'); + expect(processStdoutSpy).toHaveBeenCalledWith('Hello World\n'); expect(mockShutdownTelemetry).toHaveBeenCalled(); }); @@ -319,7 +324,7 @@ describe('runNonInteractive', () => { 'prompt-id-2', { type: SendMessageType.ToolResult }, ); - expect(processStdoutSpy).toHaveBeenCalledWith('Final answer'); + expect(processStdoutSpy).toHaveBeenCalledWith('Final answer\n'); }); it('should handle error during tool execution and should send error back to the model', async () => { @@ -388,7 +393,7 @@ describe('runNonInteractive', () => { 'prompt-id-3', { type: SendMessageType.ToolResult }, ); - expect(processStdoutSpy).toHaveBeenCalledWith('Sorry, let me try again.'); + expect(processStdoutSpy).toHaveBeenCalledWith('Sorry, let me try again.\n'); }); it('should exit with error if sendMessageStream throws initially', async () => { @@ -450,7 +455,7 @@ describe('runNonInteractive', () => { expect(mockCoreExecuteToolCall).toHaveBeenCalled(); expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); expect(processStdoutSpy).toHaveBeenCalledWith( - "Sorry, I can't find that tool.", + "Sorry, I can't find that tool.\n", ); }); @@ -514,7 +519,7 @@ describe('runNonInteractive', () => { ); // 6. Assert the final output is correct - expect(processStdoutSpy).toHaveBeenCalledWith('Summary complete.'); + expect(processStdoutSpy).toHaveBeenCalledWith('Summary complete.\n'); }); it('should process input and write JSON output with stats', async () => { @@ -887,7 +892,7 @@ describe('runNonInteractive', () => { { type: SendMessageType.UserQuery }, ); - expect(processStdoutSpy).toHaveBeenCalledWith('Response from command'); + expect(processStdoutSpy).toHaveBeenCalledWith('Response from command\n'); }); it('should handle command that requires confirmation by returning early', async () => { @@ -912,7 +917,7 @@ describe('runNonInteractive', () => { // Should write error message through adapter to stdout (TEXT mode goes through JsonOutputAdapter) expect(processStderrSpy).toHaveBeenCalledWith( - 'Shell command confirmation is not supported in non-interactive mode. Use YOLO mode or pre-approve commands.', + 'Shell command confirmation is not supported in non-interactive mode. Use YOLO mode or pre-approve commands.\n', ); }); @@ -947,7 +952,7 @@ describe('runNonInteractive', () => { { type: SendMessageType.UserQuery }, ); - expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown'); + expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown\n'); }); it('should handle known but unsupported slash commands like /help by returning early', async () => { @@ -970,7 +975,7 @@ describe('runNonInteractive', () => { // Should write error message through adapter to stdout (TEXT mode goes through JsonOutputAdapter) expect(processStderrSpy).toHaveBeenCalledWith( - 'The command "/help" is not supported in non-interactive mode.', + 'The command "/help" is not supported in non-interactive mode.\n', ); }); @@ -995,7 +1000,7 @@ describe('runNonInteractive', () => { // Should write error message to stderr expect(processStderrSpy).toHaveBeenCalledWith( - 'Unknown command result type: unhandled', + 'Unknown command result type: unhandled\n', ); }); @@ -1033,7 +1038,7 @@ describe('runNonInteractive', () => { expect(mockAction).toHaveBeenCalledWith(expect.any(Object), 'arg1 arg2'); - expect(processStdoutSpy).toHaveBeenCalledWith('Acknowledged'); + expect(processStdoutSpy).toHaveBeenCalledWith('Acknowledged\n'); }); it('should emit stream-json envelopes when output format is stream-json', async () => { diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index bbd7fab7d..b8f8e04f4 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -4,7 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Config, ToolCallRequestInfo } from '@qwen-code/qwen-code-core'; +import type { + BackgroundAgentStatus, + Config, + ToolCallRequestInfo, +} from '@qwen-code/qwen-code-core'; import { isSlashCommand } from './ui/utils/commandUtils.js'; import type { LoadedSettings } from './config/settings.js'; import { @@ -250,6 +254,54 @@ export async function runNonInteractive( const initialParts = normalizePartList(initialPartList); let currentMessages: Content[] = [{ role: 'user', parts: initialParts }]; + // Register the callback early so background agents launched during the main + // tool-call chain can push completions onto the queue. + interface LocalQueueItem { + displayText: string; + modelText: string; + sendMessageType: SendMessageType; + sdkNotification?: { + task_id: string; + tool_use_id?: string; + status: BackgroundAgentStatus; + usage?: { + total_tokens: number; + tool_uses: number; + duration_ms: number; + }; + }; + } + const localQueue: LocalQueueItem[] = []; + const registry = config.getBackgroundTaskRegistry(); + registry.setNotificationCallback((displayText, modelText, meta) => { + localQueue.push({ + displayText, + modelText, + sendMessageType: SendMessageType.Notification, + sdkNotification: { + task_id: meta.agentId, + tool_use_id: meta.toolUseId, + status: meta.status, + usage: meta.stats + ? { + total_tokens: meta.stats.totalTokens, + tool_uses: meta.stats.toolUses, + duration_ms: meta.stats.durationMs, + } + : undefined, + }, + }); + }); + + registry.setRegisterCallback((entry) => { + adapter.emitSystemMessage('task_started', { + task_id: entry.agentId, + tool_use_id: entry.toolUseId, + description: entry.description, + subagent_type: entry.subagentType, + }); + }); + let isFirstTurn = true; let modelOverride: string | undefined; while (true) { @@ -346,9 +398,6 @@ export async function runNonInteractive( }, ); - // Note: In JSON mode, subagent messages are automatically added to the main - // adapter's messages array and will be output together on emitResult() - if (toolResponse.error) { // In JSON/STREAM_JSON mode, tool errors are tolerated and formatted // as tool_result blocks. handleToolError will detect JSON/STREAM_JSON mode @@ -380,144 +429,251 @@ export async function runNonInteractive( } currentMessages = [{ role: 'user', parts: toolResponseParts }]; } else { - // No more tool calls — check if cron jobs are keeping us alive + // Shared between the normal drain and the cancellation flush so stream-json + // consumers always see a terminal task_notification paired with task_started. + const emitNotificationToSdk = (item: LocalQueueItem) => { + if (item.sendMessageType !== SendMessageType.Notification) return; + adapter.emitUserMessage([{ text: item.displayText }]); + if (item.sdkNotification) { + adapter.emitSystemMessage( + 'task_notification', + item.sdkNotification, + ); + } + }; + + // Drain-turns count toward getMaxSessionTurns() for symmetry with the main + // loop — otherwise a looping cron or a model that keeps replying to + // notifications could exceed the cap silently in headless runs. + const drainOneItem = async () => { + if (localQueue.length === 0) return; + const item = localQueue.shift()!; + + emitNotificationToSdk(item); + + turnCount++; + if ( + config.getMaxSessionTurns() >= 0 && + turnCount > config.getMaxSessionTurns() + ) { + handleMaxTurnsExceededError(config); + } + + const inputFormat = + typeof config.getInputFormat === 'function' + ? config.getInputFormat() + : InputFormat.TEXT; + const toolCallUpdateCallback = + inputFormat === InputFormat.STREAM_JSON && options.controlService + ? options.controlService.permission.getToolCallUpdateCallback() + : undefined; + + let itemMessages: Content[] = [ + { role: 'user', parts: [{ text: item.modelText }] }, + ]; + let itemIsFirstTurn = true; + let itemModelOverride: string | undefined; + + while (true) { + const itemToolCallRequests: ToolCallRequestInfo[] = []; + const itemApiStartTime = Date.now(); + const itemStream = geminiClient.sendMessageStream( + itemMessages[0]?.parts || [], + abortController.signal, + prompt_id, + { + type: itemIsFirstTurn + ? item.sendMessageType + : SendMessageType.ToolResult, + modelOverride: itemModelOverride, + ...(itemIsFirstTurn && { + notificationDisplayText: item.displayText, + }), + }, + ); + itemIsFirstTurn = false; + + adapter.startAssistantMessage(); + + for await (const event of itemStream) { + if (abortController.signal.aborted) { + // Pair the startAssistantMessage() above so stream-json mode doesn't + // leave an unterminated message_start. + adapter.finalizeAssistantMessage(); + return; + } + adapter.processEvent(event); + if (event.type === GeminiEventType.ToolCallRequest) { + itemToolCallRequests.push(event.value); + } + if ( + outputFormat === OutputFormat.TEXT && + event.type === GeminiEventType.Error + ) { + const errorText = parseAndFormatApiError( + event.value.error, + config.getContentGeneratorConfig()?.authType, + ); + process.stderr.write(`${errorText}\n`); + throw new Error(errorText); + } + } + + adapter.finalizeAssistantMessage(); + totalApiDurationMs += Date.now() - itemApiStartTime; + + if (itemToolCallRequests.length > 0) { + const itemToolResponseParts: Part[] = []; + + for (const requestInfo of itemToolCallRequests) { + const isAgentTool = requestInfo.name === 'agent'; + const { handler: outputUpdateHandler } = isAgentTool + ? createAgentToolProgressHandler( + config, + requestInfo.callId, + adapter, + ) + : createToolProgressHandler(requestInfo, adapter); + + const toolResponse = await executeToolCall( + config, + requestInfo, + abortController.signal, + { + outputUpdateHandler, + ...(toolCallUpdateCallback && { + onToolCallsUpdate: toolCallUpdateCallback, + }), + }, + ); + + if (toolResponse.error) { + handleToolError( + requestInfo.name, + toolResponse.error, + config, + toolResponse.errorType || 'TOOL_EXECUTION_ERROR', + typeof toolResponse.resultDisplay === 'string' + ? toolResponse.resultDisplay + : undefined, + ); + } + + adapter.emitToolResult(requestInfo, toolResponse); + + if (toolResponse.responseParts) { + itemToolResponseParts.push(...toolResponse.responseParts); + } + + if ('modelOverride' in toolResponse) { + itemModelOverride = toolResponse.modelOverride; + } + } + itemMessages = [{ role: 'user', parts: itemToolResponseParts }]; + } else { + break; + } + } + }; + + // Single-flight drain: concurrent callers wait for the running drain so + // cron jobs firing mid-stream don't produce overlapping turns. + // + // Clear via outer `.finally()` rather than inside the async body: when the + // queue is empty the body runs synchronously, so an inner finally would + // null the slot BEFORE the outer `drainPromise = p` assignment and leave + // it stuck forever. + let drainPromise: Promise | null = null; + const drainLocalQueue = (): Promise => { + if (drainPromise) return drainPromise; + const p = (async () => { + while (localQueue.length > 0) { + await drainOneItem(); + } + })(); + drainPromise = p; + void p.finally(() => { + if (drainPromise === p) drainPromise = null; + }); + return p; + }; + + // Start cron scheduler — fires enqueue onto the shared queue. const scheduler = !config.isCronEnabled() ? null : config.getCronScheduler(); - if (scheduler && scheduler.size > 0) { - // Start the scheduler and wait for all jobs to complete or be deleted. - // Each fired prompt is processed as a new turn through the same loop. - await new Promise((resolve) => { - const cronQueue: string[] = []; - let processing = false; - const checkDone = () => { - if (scheduler.size === 0 && !processing) { + if (scheduler && scheduler.size > 0) { + await new Promise((resolve, reject) => { + // Resolve on SIGINT/SIGTERM too — recurring cron jobs never + // drop scheduler.size to 0 on their own, so without this the + // hold-back loop below is unreachable after an abort. + const onAbort = () => { + scheduler.stop(); + resolve(); + }; + if (abortController.signal.aborted) { + onAbort(); + return; + } + abortController.signal.addEventListener('abort', onAbort, { + once: true, + }); + + const checkCronDone = () => { + if (scheduler.size === 0 && !drainPromise) { + abortController.signal.removeEventListener('abort', onAbort); scheduler.stop(); resolve(); } }; - const drainQueue = async () => { - if (processing) return; - processing = true; - try { - while (cronQueue.length > 0) { - const cronPrompt = cronQueue.shift()!; - turnCount++; - let cronMessages: Content[] = [ - { role: 'user', parts: [{ text: cronPrompt }] }, - ]; - let cronIsFirstTurn = true; - let cronModelOverride: string | undefined; - - while (true) { - const cronToolCallRequests: ToolCallRequestInfo[] = []; - const cronApiStartTime = Date.now(); - const cronStream = geminiClient.sendMessageStream( - cronMessages[0]?.parts || [], - abortController.signal, - prompt_id, - { - type: cronIsFirstTurn - ? SendMessageType.Cron - : SendMessageType.ToolResult, - modelOverride: cronModelOverride, - }, - ); - cronIsFirstTurn = false; - - adapter.startAssistantMessage(); - - for await (const event of cronStream) { - if (abortController.signal.aborted) { - const summary = scheduler.getExitSummary(); - scheduler.stop(); - if (summary) { - process.stderr.write(summary + '\n'); - } - resolve(); - return; - } - adapter.processEvent(event); - if (event.type === GeminiEventType.ToolCallRequest) { - cronToolCallRequests.push(event.value); - } - } - - adapter.finalizeAssistantMessage(); - totalApiDurationMs += Date.now() - cronApiStartTime; - - if (cronToolCallRequests.length > 0) { - const cronToolResponseParts: Part[] = []; - - for (const requestInfo of cronToolCallRequests) { - const isAgentTool = requestInfo.name === 'agent'; - const { handler: outputUpdateHandler } = isAgentTool - ? createAgentToolProgressHandler( - config, - requestInfo.callId, - adapter, - ) - : createToolProgressHandler(requestInfo, adapter); - - const toolResponse = await executeToolCall( - config, - requestInfo, - abortController.signal, - { outputUpdateHandler }, - ); - - if (toolResponse.error) { - handleToolError( - requestInfo.name, - toolResponse.error, - config, - toolResponse.errorType || 'TOOL_EXECUTION_ERROR', - typeof toolResponse.resultDisplay === 'string' - ? toolResponse.resultDisplay - : undefined, - ); - } - - adapter.emitToolResult(requestInfo, toolResponse); - - if (toolResponse.responseParts) { - cronToolResponseParts.push( - ...toolResponse.responseParts, - ); - } - - if ('modelOverride' in toolResponse) { - cronModelOverride = toolResponse.modelOverride; - } - } - cronMessages = [ - { role: 'user', parts: cronToolResponseParts }, - ]; - } else { - break; - } - } - } - } catch (error) { - debugLogger.error('Error processing cron prompt:', error); - } finally { - processing = false; - checkDone(); - } + // Propagate drain failures. Without this, a rejected + // drainLocalQueue() (e.g. a text-mode API error surfacing + // out of drainOneItem) would be swallowed by `void` and + // checkCronDone would never fire — hanging the run. + const onDrainError = (err: unknown) => { + abortController.signal.removeEventListener('abort', onAbort); + scheduler.stop(); + reject(err); }; scheduler.start((job: { prompt: string }) => { - cronQueue.push(job.prompt); - void drainQueue(); + const label = job.prompt.slice(0, 40); + localQueue.push({ + displayText: `Cron: ${label}`, + modelText: job.prompt, + sendMessageType: SendMessageType.Cron, + }); + drainLocalQueue().then(checkCronDone, onDrainError); }); - // Also check immediately in case jobs were already deleted - checkDone(); + // Check immediately in case jobs were already deleted + checkCronDone(); }); } + // Wait for running background agents to complete before emitting the final + // result. On SIGINT/SIGTERM, abort them and route through + // handleCancellationError — otherwise the success emitResult below would + // silently convert a cancellation into a completion. + while (true) { + if (abortController.signal.aborted) { + registry.abortAll(); + // Flush queued terminal notifications before handleCancellationError + // exits so stream-json consumers always see a task_notification paired + // with every task_started. + while (localQueue.length > 0) { + emitNotificationToSdk(localQueue.shift()!); + } + handleCancellationError(config); + } + await drainLocalQueue(); + const running = registry.getRunning(); + if (running.length === 0 && localQueue.length === 0) break; + await new Promise((r) => setTimeout(r, 100)); + } + const metrics = uiTelemetryService.getMetrics(); const usage = computeUsageFromMetrics(metrics); // Get stats for JSON format output @@ -567,6 +723,10 @@ export async function runNonInteractive( }); handleError(error, config); } finally { + const reg = config.getBackgroundTaskRegistry(); + reg.setNotificationCallback(undefined); + reg.setRegisterCallback(undefined); + process.stdout.removeListener('error', stdoutErrorHandler); // Cleanup signal handlers process.removeListener('SIGINT', shutdownHandler); diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 998856dd0..56f128839 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -98,6 +98,9 @@ const HistoryItemDisplayComponent: React.FC = ({ {itemForDisplay.type === 'user' && ( )} + {itemForDisplay.type === 'notification' && ( + + )} {itemForDisplay.type === 'user_shell' && ( )} diff --git a/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx b/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx index 337696ef8..c0a7d6506 100644 --- a/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx +++ b/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx @@ -45,6 +45,8 @@ const getStatusColor = ( case 'completed': case 'success': return theme.status.success; + case 'background': + return theme.text.secondary; case 'cancelled': return theme.status.warning; case 'failed': @@ -60,6 +62,8 @@ const getStatusText = (status: AgentResultDisplay['status']) => { return 'Running'; case 'completed': return 'Completed'; + case 'background': + return 'Running in background'; case 'cancelled': return 'User Cancelled'; case 'failed': diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index a13d8dd70..7483e9349 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -207,6 +207,9 @@ describe('useGeminiStream', () => { getArenaAgentClient: vi.fn(() => null), isCronEnabled: vi.fn(() => false), getCronScheduler: vi.fn(() => null), + getBackgroundTaskRegistry: vi.fn(() => ({ + setNotificationCallback: vi.fn(), + })), } as unknown as Config; mockOnDebugMessage = vi.fn(); mockHandleSlashCommand = vi.fn().mockResolvedValue(false); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index d971f25da..b584044ba 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -527,6 +527,7 @@ export const useGeminiStream = ( userMessageTimestamp: number, abortSignal: AbortSignal, prompt_id: string, + submitType: SendMessageType, ): Promise<{ queryToSend: PartListUnion | null; shouldProceed: boolean; @@ -542,6 +543,19 @@ export const useGeminiStream = ( if (typeof query === 'string') { const trimmedQuery = query.trim(); + + // Notification messages (e.g. background agent completions) are + // pre-processed by the notification drain loop which already + // added the display item to history. Just pass the model text + // through to the API. Cron prompts still go through the normal + // slash/@-command/shell preprocessing path below. + if (submitType === SendMessageType.Notification) { + onDebugMessage( + `Received notification (${trimmedQuery.length} chars)`, + ); + return { queryToSend: trimmedQuery, shouldProceed: true }; + } + onDebugMessage(`Received user query (${trimmedQuery.length} chars)`); await logger?.logMessage(MessageSenderType.USER, trimmedQuery); @@ -592,10 +606,15 @@ export const useGeminiStream = ( localQueryToSendToGemini = trimmedQuery; - addItem( - { type: MessageType.USER, text: trimmedQuery }, - userMessageTimestamp, - ); + // Cron prompts are already rendered as a `● Cron: …` notification by + // the queue drain, so skip the user-message history item to avoid + // a duplicate `> …` line. Preprocessing (@/slash/shell) still runs. + if (submitType !== SendMessageType.Cron) { + addItem( + { type: MessageType.USER, text: trimmedQuery }, + userMessageTimestamp, + ); + } // Handle @-commands (which might involve tool calls) if (isAtCommand(trimmedQuery)) { @@ -1225,6 +1244,7 @@ export const useGeminiStream = ( query: PartListUnion, submitType: SendMessageType = SendMessageType.UserQuery, prompt_id?: string, + metadata?: { notificationDisplayText?: string }, ) => { const allowConcurrentBtwDuringResponse = submitType === SendMessageType.UserQuery && @@ -1301,6 +1321,7 @@ export const useGeminiStream = ( userMessageTimestamp, abortSignal, prompt_id!, + submitType, ); if (!shouldProceed || queryToSend === null) { @@ -1365,7 +1386,11 @@ export const useGeminiStream = ( finalQueryToSend, abortSignal, prompt_id!, - { type: submitType, modelOverride: modelOverrideRef.current }, + { + type: submitType, + notificationDisplayText: metadata?.notificationDisplayText, + modelOverride: modelOverrideRef.current, + }, ); const processingStatus = await processGeminiStreamEvents( @@ -1843,17 +1868,29 @@ export const useGeminiStream = ( storage, ]); - // ─── Cron scheduler integration ───────────────────────── - const cronQueueRef = useRef([]); - const [cronTrigger, setCronTrigger] = useState(0); + // ─── Unified notification queue (cron + background agents) ────── + const notificationQueueRef = useRef< + Array<{ + displayText: string; + modelText: string; + sendMessageType: SendMessageType; + }> + >([]); + const [notificationTrigger, setNotificationTrigger] = useState(0); - // Start the scheduler on mount, stop on unmount + // Start the cron scheduler on mount, stop on unmount. + // Cron fires enqueue onto the shared notification queue. useEffect(() => { if (!config.isCronEnabled()) return; const scheduler = config.getCronScheduler(); scheduler.start((job: { prompt: string }) => { - cronQueueRef.current.push(job.prompt); - setCronTrigger((n) => n + 1); + const label = job.prompt.slice(0, 40); + notificationQueueRef.current.push({ + displayText: `Cron: ${label}`, + modelText: job.prompt, + sendMessageType: SendMessageType.Cron, + }); + setNotificationTrigger((n) => n + 1); }); return () => { const summary = scheduler.getExitSummary(); @@ -1864,16 +1901,38 @@ export const useGeminiStream = ( }; }, [config]); - // When idle, drain the cron queue one prompt at a time + // Register background agent notification callback onto the shared queue. + useEffect(() => { + const registry = config.getBackgroundTaskRegistry(); + registry.setNotificationCallback((displayText, modelText) => { + notificationQueueRef.current.push({ + displayText, + modelText, + sendMessageType: SendMessageType.Notification, + }); + setNotificationTrigger((n) => n + 1); + }); + return () => { + registry.setNotificationCallback(undefined); + }; + }, [config]); + + // When idle, drain the unified queue one item at a time. useEffect(() => { if ( streamingState === StreamingState.Idle && - cronQueueRef.current.length > 0 + notificationQueueRef.current.length > 0 ) { - const prompt = cronQueueRef.current.shift()!; - submitQuery(prompt, SendMessageType.Cron); + const item = notificationQueueRef.current.shift()!; + addItem( + { type: 'notification' as const, text: item.displayText }, + Date.now(), + ); + submitQuery(item.modelText, item.sendMessageType, undefined, { + notificationDisplayText: item.displayText, + }); } - }, [streamingState, submitQuery, cronTrigger]); + }, [streamingState, submitQuery, notificationTrigger, addItem]); return { streamingState, diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 41216874c..364a3b575 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -208,6 +208,11 @@ export type HistoryItemToolGroup = HistoryItemBase & { isUserInitiated?: boolean; }; +export type HistoryItemNotification = HistoryItemBase & { + type: 'notification'; + text: string; +}; + export type HistoryItemUserShell = HistoryItemBase & { type: 'user_shell'; text: string; @@ -418,6 +423,7 @@ export type HistoryItemStopHookSystemMessage = HistoryItemBase & { // Individually exported types extending HistoryItemBase export type HistoryItemWithoutId = | HistoryItemUser + | HistoryItemNotification | HistoryItemUserShell | HistoryItemGemini | HistoryItemGeminiContent diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.ts index aa30a0489..bf134785a 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.ts @@ -256,6 +256,22 @@ function convertToHistoryItems( } switch (record.type) { case 'user': { + // Restore notification items (background agent completions and cron fires) + if (record.subtype === 'notification' || record.subtype === 'cron') { + const payload = record.systemPayload as + | { displayText?: string } + | undefined; + const fallback = + record.subtype === 'cron' + ? 'Cron job fired' + : 'Background agent completed'; + const text = + payload?.displayText || + extractTextFromParts(record.message?.parts as Part[]) || + fallback; + items.push({ type: 'notification', text }); + break; + } if (pendingAtCommands.length > 0) { // Flush any pending tool group before user message if (currentToolGroup.length > 0) { diff --git a/packages/core/src/agents/background-tasks.test.ts b/packages/core/src/agents/background-tasks.test.ts new file mode 100644 index 000000000..3a401e15b --- /dev/null +++ b/packages/core/src/agents/background-tasks.test.ts @@ -0,0 +1,287 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { BackgroundTaskRegistry } from './background-tasks.js'; + +describe('BackgroundTaskRegistry', () => { + let registry: BackgroundTaskRegistry; + + beforeEach(() => { + registry = new BackgroundTaskRegistry(); + }); + + it('registers and retrieves a background agent', () => { + const entry = { + agentId: 'test-1', + description: 'test agent', + status: 'running' as const, + startTime: Date.now(), + abortController: new AbortController(), + }; + + registry.register(entry); + expect(registry.get('test-1')).toBe(entry); + }); + + it('completes a background agent and sends notification', () => { + const callback = vi.fn(); + registry.setNotificationCallback(callback); + + registry.register({ + agentId: 'test-1', + description: 'test agent', + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + }); + + registry.complete('test-1', 'The result text'); + + const entry = registry.get('test-1')!; + expect(entry.status).toBe('completed'); + expect(entry.result).toBe('The result text'); + expect(entry.endTime).toBeDefined(); + expect(callback).toHaveBeenCalledOnce(); + const [displayText, modelText] = callback.mock.calls[0] as [string, string]; + // Display text: short summary without the full result + expect(displayText).toContain('completed'); + expect(displayText).toContain('test agent'); + expect(displayText).not.toContain('The result text'); + // Model text: full details including result for the LLM + expect(modelText).toContain('The result text'); + }); + + it('fails a background agent and sends notification', () => { + const callback = vi.fn(); + registry.setNotificationCallback(callback); + + registry.register({ + agentId: 'test-1', + description: 'test agent', + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + }); + + registry.fail('test-1', 'Something went wrong'); + + const entry = registry.get('test-1')!; + expect(entry.status).toBe('failed'); + expect(entry.error).toBe('Something went wrong'); + expect(callback).toHaveBeenCalledOnce(); + const [displayText] = callback.mock.calls[0] as [string, string]; + expect(displayText).toContain('failed'); + }); + + it('cancels a running background agent', () => { + const abortController = new AbortController(); + + registry.register({ + agentId: 'test-1', + description: 'test agent', + status: 'running', + startTime: Date.now(), + abortController, + }); + + registry.cancel('test-1'); + + expect(registry.get('test-1')!.status).toBe('cancelled'); + expect(abortController.signal.aborted).toBe(true); + }); + + it('does not cancel a non-running agent', () => { + const abortController = new AbortController(); + + registry.register({ + agentId: 'test-1', + description: 'test agent', + status: 'running', + startTime: Date.now(), + abortController, + }); + + registry.complete('test-1', 'done'); + registry.cancel('test-1'); // should be a no-op + + expect(registry.get('test-1')!.status).toBe('completed'); + expect(abortController.signal.aborted).toBe(false); + }); + + it('lists running agents', () => { + registry.register({ + agentId: 'a', + description: 'agent a', + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + }); + registry.register({ + agentId: 'b', + description: 'agent b', + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + }); + + registry.complete('a', 'done'); + + const running = registry.getRunning(); + expect(running).toHaveLength(1); + expect(running[0].agentId).toBe('b'); + }); + + it('aborts all running agents', () => { + const ac1 = new AbortController(); + const ac2 = new AbortController(); + + registry.register({ + agentId: 'a', + description: 'agent a', + status: 'running', + startTime: Date.now(), + abortController: ac1, + }); + registry.register({ + agentId: 'b', + description: 'agent b', + status: 'running', + startTime: Date.now(), + abortController: ac2, + }); + + registry.abortAll(); + + expect(ac1.signal.aborted).toBe(true); + expect(ac2.signal.aborted).toBe(true); + expect(registry.get('a')!.status).toBe('cancelled'); + expect(registry.get('b')!.status).toBe('cancelled'); + }); + + it('complete is a no-op after cancellation (state race guard)', () => { + const callback = vi.fn(); + registry.setNotificationCallback(callback); + + registry.register({ + agentId: 'test-1', + description: 'test agent', + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + }); + + registry.cancel('test-1'); + registry.complete('test-1', 'late result'); + + // Status should remain 'cancelled', not flip to 'completed' + expect(registry.get('test-1')!.status).toBe('cancelled'); + // Exactly one notification, emitted by cancel() itself — the late + // complete() must be no-op'd by the running-status guard. + expect(callback).toHaveBeenCalledTimes(1); + const [, modelText] = callback.mock.calls[0]; + expect(modelText).toContain('cancelled'); + }); + + it('fail is a no-op after cancellation (state race guard)', () => { + const callback = vi.fn(); + registry.setNotificationCallback(callback); + + registry.register({ + agentId: 'test-1', + description: 'test agent', + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + }); + + registry.cancel('test-1'); + registry.fail('test-1', 'late error'); + + expect(registry.get('test-1')!.status).toBe('cancelled'); + expect(callback).toHaveBeenCalledTimes(1); + const [, modelText] = callback.mock.calls[0]; + expect(modelText).toContain('cancelled'); + }); + + it('does not send notification without callback', () => { + registry.register({ + agentId: 'test-1', + description: 'test agent', + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + }); + + // Should not throw + registry.complete('test-1', 'done'); + expect(registry.get('test-1')!.status).toBe('completed'); + }); + + it('propagates toolUseId through XML and notification meta', () => { + const callback = vi.fn(); + registry.setNotificationCallback(callback); + + registry.register({ + agentId: 'test-1', + description: 'test agent', + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + toolUseId: 'call-abc-123', + }); + + registry.complete('test-1', 'done'); + + expect(callback).toHaveBeenCalledOnce(); + const [, modelText, meta] = callback.mock.calls[0]; + expect(modelText).toContain('call-abc-123'); + expect(meta.toolUseId).toBe('call-abc-123'); + }); + + it('omits tool-use-id XML tag when toolUseId is absent', () => { + const callback = vi.fn(); + registry.setNotificationCallback(callback); + + registry.register({ + agentId: 'test-1', + description: 'test agent', + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + }); + + registry.complete('test-1', 'done'); + + const [, modelText, meta] = callback.mock.calls[0]; + expect(modelText).not.toContain(''); + expect(meta.toolUseId).toBeUndefined(); + }); + + it('escapes XML metacharacters in interpolated fields', () => { + const callback = vi.fn(); + registry.setNotificationCallback(callback); + + registry.register({ + agentId: 'test-1', + description: 'summarize & ', + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + }); + + registry.complete('test-1', 'here is bold & '); + + const [, modelText] = callback.mock.calls[0]; + // No injected closing tags — subagent text is escaped so the + // parent envelope stays a single task-notification element. + expect(modelText.match(/<\/task-notification>/g)!.length).toBe(1); + expect(modelText).toContain('</result>'); + expect(modelText).toContain('</task-notification>'); + expect(modelText).toContain('<b>bold</b>'); + expect(modelText).toContain('&'); + }); +}); diff --git a/packages/core/src/agents/background-tasks.ts b/packages/core/src/agents/background-tasks.ts new file mode 100644 index 000000000..aa3b60b69 --- /dev/null +++ b/packages/core/src/agents/background-tasks.ts @@ -0,0 +1,248 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview BackgroundTaskRegistry — tracks background (async) sub-agents. + * + * When the Agent tool is called with `run_in_background: true`, the sub-agent + * runs asynchronously. This registry tracks the lifecycle of each background + * agent so the parent can be notified on completion. + */ + +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('BACKGROUND_TASKS'); + +const MAX_DESCRIPTION_LENGTH = 40; +const MAX_RESULT_LENGTH = 2000; + +// Escape text so it is safe to interpolate into an XML element body. +// Subagent-produced strings (description, result, error) can contain `<`, +// `>`, or literal `` — without escaping, a subagent +// summarizing HTML or another agent's notification could close the +// envelope early and forge sibling tags (e.g. a faked ) that the +// parent model would treat as trusted metadata. +function escapeXml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>'); +} + +export type BackgroundAgentStatus = + | 'running' + | 'completed' + | 'failed' + | 'cancelled'; + +export interface AgentCompletionStats { + totalTokens: number; + toolUses: number; + durationMs: number; +} + +export interface BackgroundAgentEntry { + agentId: string; + description: string; + subagentType?: string; + status: BackgroundAgentStatus; + startTime: number; + endTime?: number; + result?: string; + error?: string; + abortController: AbortController; + stats?: AgentCompletionStats; + toolUseId?: string; +} + +export interface NotificationMeta { + agentId: string; + status: BackgroundAgentStatus; + stats?: AgentCompletionStats; + toolUseId?: string; +} + +export type BackgroundNotificationCallback = ( + displayText: string, + modelText: string, + meta: NotificationMeta, +) => void; + +export type BackgroundRegisterCallback = (entry: BackgroundAgentEntry) => void; + +export class BackgroundTaskRegistry { + private readonly agents = new Map(); + private notificationCallback?: BackgroundNotificationCallback; + private registerCallback?: BackgroundRegisterCallback; + + register(entry: BackgroundAgentEntry): void { + this.agents.set(entry.agentId, entry); + debugLogger.info(`Registered background agent: ${entry.agentId}`); + + if (this.registerCallback) { + try { + this.registerCallback(entry); + } catch (error) { + debugLogger.error('Failed to emit register callback:', error); + } + } + } + + // No-op if not 'running' — guards against race with concurrent cancellation. + complete( + agentId: string, + result: string, + stats?: AgentCompletionStats, + ): void { + const entry = this.agents.get(agentId); + if (!entry || entry.status !== 'running') return; + + entry.status = 'completed'; + entry.endTime = Date.now(); + entry.result = result; + entry.stats = stats; + debugLogger.info(`Background agent completed: ${agentId}`); + + this.emitNotification(entry); + } + + // No-op if not 'running' — guards against race with concurrent cancellation. + fail(agentId: string, error: string, stats?: AgentCompletionStats): void { + const entry = this.agents.get(agentId); + if (!entry || entry.status !== 'running') return; + + entry.status = 'failed'; + entry.endTime = Date.now(); + entry.error = error; + entry.stats = stats; + debugLogger.info(`Background agent failed: ${agentId}`); + + this.emitNotification(entry); + } + + // Emit the terminal notification here — the fire-and-forget complete()/fail() + // path is guarded by `status !== 'running'` and will no-op, so without this the + // SDK contract breaks: consumers saw task_started but never receive a matching + // task_notification. + cancel(agentId: string): void { + const entry = this.agents.get(agentId); + if (!entry || entry.status !== 'running') return; + + entry.abortController.abort(); + entry.status = 'cancelled'; + entry.endTime = Date.now(); + debugLogger.info(`Background agent cancelled: ${agentId}`); + + this.emitNotification(entry); + } + + get(agentId: string): BackgroundAgentEntry | undefined { + return this.agents.get(agentId); + } + + getRunning(): BackgroundAgentEntry[] { + return Array.from(this.agents.values()).filter( + (e) => e.status === 'running', + ); + } + + setNotificationCallback( + cb: BackgroundNotificationCallback | undefined, + ): void { + this.notificationCallback = cb; + } + + setRegisterCallback(cb: BackgroundRegisterCallback | undefined): void { + this.registerCallback = cb; + } + + abortAll(): void { + for (const entry of Array.from(this.agents.values())) { + this.cancel(entry.agentId); + } + debugLogger.info('Aborted all background agents'); + } + + private buildDisplayLabel(entry: BackgroundAgentEntry): string { + // Strip the subagent type prefix if the description already starts with it + // to avoid duplication like "Explore: Explore: list ts files". + let rawDesc = entry.description; + if ( + entry.subagentType && + rawDesc.toLowerCase().startsWith(entry.subagentType.toLowerCase() + ':') + ) { + rawDesc = rawDesc.slice(entry.subagentType.length + 1).trimStart(); + } + const desc = + rawDesc.length > MAX_DESCRIPTION_LENGTH + ? rawDesc.slice(0, MAX_DESCRIPTION_LENGTH) + '...' + : rawDesc; + return entry.subagentType ? `${entry.subagentType}: ${desc}` : desc; + } + + private emitNotification(entry: BackgroundAgentEntry): void { + if (!this.notificationCallback) return; + + const statusText = + entry.status === 'completed' + ? 'completed' + : entry.status === 'failed' + ? 'failed' + : 'was cancelled'; + + const label = this.buildDisplayLabel(entry); + const displayLine = `Background agent "${label}" ${statusText}.`; + + // Truncate before escaping so we don't slice through an escape + // sequence (e.g. mid-`&`) and emit malformed XML. + const rawResult = entry.result + ? entry.result.length > MAX_RESULT_LENGTH + ? entry.result.slice(0, MAX_RESULT_LENGTH) + '\n[truncated]' + : entry.result + : undefined; + + const xmlParts: string[] = [ + '', + `${escapeXml(entry.agentId)}`, + ]; + if (entry.toolUseId) { + xmlParts.push(`${escapeXml(entry.toolUseId)}`); + } + xmlParts.push( + `${escapeXml(entry.status)}`, + `Agent "${escapeXml(entry.description)}" ${statusText}.`, + ); + if (rawResult) { + xmlParts.push(`${escapeXml(rawResult)}`); + } + if (entry.error) { + xmlParts.push(`Error: ${escapeXml(entry.error)}`); + } + if (entry.stats) { + xmlParts.push( + '', + `${entry.stats.totalTokens}`, + `${entry.stats.toolUses}`, + `${entry.stats.durationMs}`, + '', + ); + } + xmlParts.push(''); + + const meta: NotificationMeta = { + agentId: entry.agentId, + status: entry.status, + stats: entry.stats, + toolUseId: entry.toolUseId, + }; + + try { + this.notificationCallback(displayLine, xmlParts.join('\n'), meta); + } catch (error) { + debugLogger.error('Failed to emit background notification:', error); + } + } +} diff --git a/packages/core/src/agents/index.ts b/packages/core/src/agents/index.ts index d29d4dc09..b469ceddb 100644 --- a/packages/core/src/agents/index.ts +++ b/packages/core/src/agents/index.ts @@ -16,3 +16,4 @@ export * from './backends/index.js'; export * from './arena/index.js'; export * from './runtime/index.js'; +export * from './background-tasks.js'; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 23235f4ab..d1be8c480 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -78,6 +78,7 @@ import { SkillManager } from '../skills/skill-manager.js'; import { PermissionManager } from '../permissions/permission-manager.js'; import { SubagentManager } from '../subagents/subagent-manager.js'; import type { SubagentConfig } from '../subagents/types.js'; +import { BackgroundTaskRegistry } from '../agents/background-tasks.js'; import { DEFAULT_OTLP_ENDPOINT, DEFAULT_TELEMETRY_TARGET, @@ -533,6 +534,7 @@ export class Config { private toolRegistry!: ToolRegistry; private promptRegistry!: PromptRegistry; private subagentManager!: SubagentManager; + private readonly backgroundTaskRegistry = new BackgroundTaskRegistry(); private extensionManager!: ExtensionManager; private skillManager: SkillManager | null = null; private permissionManager: PermissionManager | null = null; @@ -1508,6 +1510,8 @@ export class Config { await this.toolRegistry.stop(); } + this.backgroundTaskRegistry.abortAll(); + await this.cleanupArenaRuntime(); } catch (error) { // Log but don't throw - cleanup should be best-effort @@ -2315,6 +2319,19 @@ export class Config { return this.subagentManager; } + getBackgroundTaskRegistry(): BackgroundTaskRegistry { + return this.backgroundTaskRegistry; + } + + /** + * Whether interactive permission prompts should be auto-denied. + * True for background agents that have no UI to show prompts. + * PermissionRequest hooks still run and can override the denial. + */ + getShouldAvoidPermissionPrompts(): boolean { + return false; + } + getSkillManager(): SkillManager | null { return this.skillManager; } diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 0a7cb7850..493c21869 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -102,6 +102,8 @@ export enum SendMessageType { Hook = 'hook', /** Cron-fired prompt. Behaves like UserQuery but skips UserPromptSubmit hook. */ Cron = 'cron', + /** Background agent notification. Display item is added by the drain loop. */ + Notification = 'notification', } export interface SendMessageOptions { @@ -111,6 +113,8 @@ export interface SendMessageOptions { iterationCount: number; reasons: string[]; }; + /** Display text for notification messages (persisted for session resume). */ + notificationDisplayText?: string; /** Model override from skill execution. When present, overrides the session model for this turn. */ modelOverride?: string; } @@ -598,6 +602,7 @@ export class GeminiClient { if ( messageType !== SendMessageType.Retry && messageType !== SendMessageType.Cron && + messageType !== SendMessageType.Notification && hooksEnabled && messageBus && this.config.hasHooksForEvent('UserPromptSubmit') @@ -642,13 +647,28 @@ export class GeminiClient { } } + if (messageType === SendMessageType.Notification) { + this.config + .getChatRecordingService() + ?.recordNotification(request, options?.notificationDisplayText); + } + + // Notifications start a fresh Turn with a new prompt_id, so the loop + // detector must reset — otherwise a prior turn's count can trip + // LoopDetected early on the notification turn. + if ( + messageType === SendMessageType.UserQuery || + messageType === SendMessageType.Cron || + messageType === SendMessageType.Notification + ) { + this.loopDetector.reset(prompt_id); + this.lastPromptId = prompt_id; + } + if ( messageType === SendMessageType.UserQuery || messageType === SendMessageType.Cron ) { - this.loopDetector.reset(prompt_id); - this.lastPromptId = prompt_id; - if (this.config.getManagedAutoMemoryEnabled()) { relevantAutoMemoryPromise = this.config .getMemoryManager() @@ -665,8 +685,14 @@ export class GeminiClient { }); } - // record user message for session management - this.config.getChatRecordingService()?.recordUserMessage(request); + // record user/cron message for session management + if (messageType === SendMessageType.Cron) { + this.config + .getChatRecordingService() + ?.recordCronPrompt(request, options?.notificationDisplayText); + } else { + this.config.getChatRecordingService()?.recordUserMessage(request); + } // Idle cleanup: clear stale thinking blocks after idle period. // Latch: once triggered, never revert — prevents oscillation. diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index ea1884684..27007c94c 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -610,6 +610,7 @@ export class CoreToolScheduler { const invocationOrError = this.buildInvocation( call.tool, args as Record, + targetCallId, ); if (invocationOrError instanceof Error) { const response = createErrorResponse( @@ -646,9 +647,17 @@ export class CoreToolScheduler { private buildInvocation( tool: AnyDeclarativeTool, args: object, + callId?: string, ): AnyToolInvocation | Error { try { - return tool.build(structuredClone(args)); + const invocation = tool.build(structuredClone(args)); + if (callId) { + const maybeAware = invocation as { setCallId?: (id: string) => void }; + if (typeof maybeAware.setCallId === 'function') { + maybeAware.setCallId(callId); + } + } + return invocation; } catch (e) { if (e instanceof Error) { return e; @@ -827,6 +836,7 @@ export class CoreToolScheduler { const invocationOrError = this.buildInvocation( toolInstance, reqInfo.args, + reqInfo.callId, ); if (invocationOrError instanceof Error) { const error = reqInfo.wasOutputTruncated @@ -1011,12 +1021,12 @@ export class CoreToolScheduler { /** * In non-interactive mode, automatically deny. */ - const shouldAutoDeny = + const isNonInteractiveDeny = !this.config.isInteractive() && !this.config.getExperimentalZedIntegration() && this.config.getInputFormat() !== InputFormat.STREAM_JSON; - if (shouldAutoDeny) { + if (isNonInteractiveDeny) { const errorMessage = `Qwen Code requires permission to use "${reqInfo.name}", but that permission was declined (non-interactive mode cannot prompt for confirmation).`; this.setStatusInternal( reqInfo.callId, @@ -1031,6 +1041,8 @@ export class CoreToolScheduler { } // Fire PermissionRequest hook before showing the permission dialog. + // Hooks run before the background-agent auto-deny so they can + // override the denial with policy-based decisions. const messageBus = this.config.getMessageBus() as | MessageBus | undefined; @@ -1095,6 +1107,22 @@ export class CoreToolScheduler { } } + // Background agents can't show interactive prompts. + // Auto-deny after hooks have had a chance to decide. + if (this.config.getShouldAvoidPermissionPrompts?.()) { + const errorMessage = `Tool "${reqInfo.name}" requires permission, but background agents cannot prompt for confirmation. The tool call was denied.`; + this.setStatusInternal( + reqInfo.callId, + 'error', + createErrorResponse( + reqInfo, + new Error(errorMessage), + ToolErrorType.EXECUTION_DENIED, + ), + ); + continue; + } + // Allow IDE to resolve confirmation this.openIdeDiffIfEnabled( confirmationDetails, diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 0e021fa4c..b4b2c6fa0 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -57,7 +57,9 @@ export interface ChatRecord { | 'chat_compression' | 'slash_command' | 'ui_telemetry' - | 'at_command'; + | 'at_command' + | 'notification' + | 'cron'; /** Working directory at time of message */ cwd: string; /** CLI version for compatibility tracking */ @@ -97,7 +99,12 @@ export interface ChatRecord { | ChatCompressionRecordPayload | SlashCommandRecordPayload | UiTelemetryRecordPayload - | AtCommandRecordPayload; + | AtCommandRecordPayload + | NotificationRecordPayload; +} + +export interface NotificationRecordPayload { + displayText: string; } /** @@ -294,6 +301,44 @@ export class ChatRecordingService { } } + /** + * Records a cron-fired prompt. + * Stored as a user-role message with subtype 'cron' so the UI + * restores it as a notification item instead of a user turn. + */ + recordCronPrompt(message: PartListUnion, displayText?: string): void { + this.recordNotificationLike(message, 'cron', displayText); + } + + /** + * Records a background agent notification. + * Stored as a user-role message with subtype 'notification' so the + * UI restores it as an info item, not a user turn. + */ + recordNotification(message: PartListUnion, displayText?: string): void { + this.recordNotificationLike(message, 'notification', displayText); + } + + private recordNotificationLike( + message: PartListUnion, + subtype: 'notification' | 'cron', + displayText?: string, + ): void { + try { + const record: ChatRecord = { + ...this.createBaseRecord('user'), + subtype, + message: createUserContent(message), + systemPayload: displayText + ? ({ displayText } as NotificationRecordPayload) + : undefined, + }; + this.appendRecord(record); + } catch (error) { + debugLogger.error(`Error saving ${subtype} record:`, error); + } + } + /** * Records an assistant turn with all available data. * Writes immediately to disk. diff --git a/packages/core/src/subagents/subagent-manager.test.ts b/packages/core/src/subagents/subagent-manager.test.ts index 7f94e9031..82e3c620e 100644 --- a/packages/core/src/subagents/subagent-manager.test.ts +++ b/packages/core/src/subagents/subagent-manager.test.ts @@ -132,6 +132,16 @@ describe('SubagentManager', () => { runConfig: { max_time_minutes: 5, max_turns: 10 }, }; } + if (yamlString.includes('background:')) { + const bgMatch = yamlString.match(/background:\s*"?(true|false)"?/); + const bgValue = bgMatch?.[1] === 'true' ? true : false; + return { + name: yamlString.match(/name:\s*(\S+)/)?.[1] ?? 'test-agent', + description: + yamlString.match(/description:\s*(.+)/)?.[1] ?? 'A test subagent', + background: bgValue, + }; + } if (yamlString.includes('name: agent1')) { return { name: 'agent1', description: 'First agent' }; } @@ -486,6 +496,73 @@ You are a helpful assistant. consoleSpy.mockRestore(); }); + + it('should parse background: true from frontmatter', () => { + const markdownWithBackground = `--- +name: monitor +description: A background monitor +background: true +--- + +You are a monitor. +`; + + const config = manager.parseSubagentContent( + markdownWithBackground, + validConfig.filePath!, + 'project', + ); + + expect(config.background).toBe(true); + }); + + it('should parse background: "true" string from frontmatter', () => { + const markdownWithBgString = `--- +name: monitor +description: A background monitor +background: "true" +--- + +You are a monitor. +`; + + const config = manager.parseSubagentContent( + markdownWithBgString, + validConfig.filePath!, + 'project', + ); + + expect(config.background).toBe(true); + }); + + it('should not set background when background: false', () => { + const markdownWithBgFalse = `--- +name: monitor +description: A foreground agent +background: false +--- + +You are an agent. +`; + + const config = manager.parseSubagentContent( + markdownWithBgFalse, + validConfig.filePath!, + 'project', + ); + + expect(config.background).toBeUndefined(); + }); + + it('should not set background when omitted', () => { + const config = manager.parseSubagentContent( + validMarkdown, + validConfig.filePath!, + 'project', + ); + + expect(config.background).toBeUndefined(); + }); }); describe('serializeSubagent', () => { @@ -564,6 +641,37 @@ You are a helpful assistant. expect(parsed.disallowedTools).toEqual(['write_file', 'mcp__slack']); }); + + it('should serialize background: true', () => { + const configWithBackground: SubagentConfig = { + ...validConfig, + background: true, + }; + + const serialized = manager.serializeSubagent(configWithBackground); + expect(serialized).toContain('background: true'); + }); + + it('should not serialize background when undefined', () => { + const serialized = manager.serializeSubagent(validConfig); + expect(serialized).not.toContain('background'); + }); + + it('should roundtrip background through serialize and parse', () => { + const configWithBackground: SubagentConfig = { + ...validConfig, + background: true, + }; + + const serialized = manager.serializeSubagent(configWithBackground); + const parsed = manager.parseSubagentContent( + serialized, + validConfig.filePath!, + 'project', + ); + + expect(parsed.background).toBe(true); + }); }); describe('createSubagent', () => { diff --git a/packages/core/src/subagents/subagent-manager.ts b/packages/core/src/subagents/subagent-manager.ts index 50e9cff5b..dcac1a880 100644 --- a/packages/core/src/subagents/subagent-manager.ts +++ b/packages/core/src/subagents/subagent-manager.ts @@ -606,6 +606,10 @@ export class SubagentManager { frontmatter['approvalMode'] = config.approvalMode; } + if (config.background) { + frontmatter['background'] = true; + } + // Serialize to YAML const yamlContent = stringifyYaml(frontmatter, { lineWidth: 0, // Disable line wrapping @@ -1087,6 +1091,21 @@ function parseSubagentContent( ? legacyModelConfig['model'] : undefined; + const backgroundRaw = frontmatter['background']; + if ( + backgroundRaw !== undefined && + backgroundRaw !== 'true' && + backgroundRaw !== 'false' && + backgroundRaw !== true && + backgroundRaw !== false + ) { + debugLogger.warn( + `Agent file ${filePath} has invalid background value '${backgroundRaw}'. Must be 'true', 'false', or omitted.`, + ); + } + const background = + backgroundRaw === 'true' || backgroundRaw === true ? true : undefined; + const config: SubagentConfig = { name, description, @@ -1099,6 +1118,7 @@ function parseSubagentContent( runConfig: runConfig as Partial, color, level, + ...(background ? { background } : {}), }; // Validate the parsed configuration diff --git a/packages/core/src/subagents/types.ts b/packages/core/src/subagents/types.ts index aa23e5e7f..febfafe49 100644 --- a/packages/core/src/subagents/types.ts +++ b/packages/core/src/subagents/types.ts @@ -100,6 +100,13 @@ export interface SubagentConfig { */ color?: string; + /** + * When true, this agent always runs as a background task when spawned. + * OR'd with the `run_in_background` tool parameter — if either is true, + * the agent runs in the background. + */ + background?: boolean; + /** * Indicates whether this is a built-in agent. * Built-in agents cannot be modified or deleted. diff --git a/packages/core/src/tools/agent/agent.test.ts b/packages/core/src/tools/agent/agent.test.ts index 2a1b603f6..b045b17a2 100644 --- a/packages/core/src/tools/agent/agent.test.ts +++ b/packages/core/src/tools/agent/agent.test.ts @@ -1108,7 +1108,7 @@ describe('AgentTool', () => { .calls[0]?.[0] as string; expect(startAgentId).toBe(stopAgentId); - expect(startAgentId).toMatch(/^file-search-\d+$/); + expect(startAgentId).toMatch(/^file-search-[0-9a-f]{8}$/); }); }); @@ -1402,6 +1402,171 @@ describe('AgentTool', () => { expect(snapshots.some((s) => !s.hasPendingConfirmation)).toBe(true); }); }); + + describe('Agent-level background: true', () => { + let mockAgent: AgentHeadless; + let mockContextState: ContextState; + let mockRegistry: { + register: ReturnType; + complete: ReturnType; + fail: ReturnType; + }; + + const bgSubagent: SubagentConfig = { + name: 'monitor', + description: 'Background monitor agent', + systemPrompt: 'You are a monitor.', + level: 'project', + filePath: '/project/.qwen/agents/monitor.md', + background: true, + }; + + beforeEach(() => { + mockAgent = { + execute: vi.fn().mockResolvedValue(undefined), + getFinalText: vi.fn().mockReturnValue('Monitor done'), + getTerminateMode: vi.fn().mockReturnValue(AgentTerminateMode.GOAL), + getExecutionSummary: vi.fn().mockReturnValue({}), + } as unknown as AgentHeadless; + + mockContextState = { set: vi.fn() } as unknown as ContextState; + MockedContextState.mockImplementation(() => mockContextState); + + mockRegistry = { + register: vi.fn(), + complete: vi.fn(), + fail: vi.fn(), + }; + + vi.mocked(config.getApprovalMode).mockReturnValue(ApprovalMode.DEFAULT); + (config as unknown as Record)['isInteractive'] = vi + .fn() + .mockReturnValue(true); + (config as unknown as Record)[ + 'getBackgroundTaskRegistry' + ] = vi.fn().mockReturnValue(mockRegistry); + + vi.mocked(mockSubagentManager.loadSubagent).mockResolvedValue(bgSubagent); + vi.mocked(mockSubagentManager.createAgentHeadless).mockResolvedValue( + mockAgent, + ); + }); + + it('should run in background when agent definition has background: true', async () => { + const params: AgentParams = { + description: 'Start monitor', + prompt: 'Watch for changes', + subagent_type: 'monitor', + }; + + const invocation = ( + agentTool as AgentToolWithProtectedMethods + ).createInvocation(params); + const result = await invocation.execute(); + + const llmText = partToString(result.llmContent); + expect(llmText).toContain('Background agent launched'); + expect(mockRegistry.register).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'Start monitor', + subagentType: 'monitor', + status: 'running', + }), + ); + const display = result.returnDisplay as AgentResultDisplay; + expect(display.status).toBe('background'); + }); + + it('should run in background when run_in_background is true even without background config', async () => { + const fgSubagent: SubagentConfig = { + ...bgSubagent, + name: 'file-search', + background: undefined, + }; + vi.mocked(mockSubagentManager.loadSubagent).mockResolvedValue(fgSubagent); + + const params: AgentParams = { + description: 'Search files', + prompt: 'Find all TypeScript files', + subagent_type: 'file-search', + run_in_background: true, + }; + + const invocation = ( + agentTool as AgentToolWithProtectedMethods + ).createInvocation(params); + const result = await invocation.execute(); + + const llmText = partToString(result.llmContent); + expect(llmText).toContain('Background agent launched'); + expect(mockRegistry.register).toHaveBeenCalled(); + }); + + it('should run in foreground when neither flag is set', async () => { + const fgSubagent: SubagentConfig = { + ...bgSubagent, + name: 'file-search', + background: undefined, + }; + vi.mocked(mockSubagentManager.loadSubagent).mockResolvedValue(fgSubagent); + + const params: AgentParams = { + description: 'Search files', + prompt: 'Find all TypeScript files', + subagent_type: 'file-search', + }; + + const invocation = ( + agentTool as AgentToolWithProtectedMethods + ).createInvocation(params); + const result = await invocation.execute(); + + const llmText = partToString(result.llmContent); + expect(llmText).not.toContain('Background agent launched'); + expect(mockRegistry.register).not.toHaveBeenCalled(); + }); + + it('should allow background in non-interactive mode (headless support)', async () => { + vi.mocked( + config.isInteractive as ReturnType, + ).mockReturnValue(false); + + const params: AgentParams = { + description: 'Start monitor', + prompt: 'Watch for changes', + subagent_type: 'monitor', + }; + + const invocation = ( + agentTool as AgentToolWithProtectedMethods + ).createInvocation(params); + const result = await invocation.execute(); + + const llmText = partToString(result.llmContent); + expect(llmText).toContain('Background agent launched'); + expect(mockRegistry.register).toHaveBeenCalled(); + }); + + it('forwards the scheduler-provided callId as toolUseId on the registry entry', async () => { + const params: AgentParams = { + description: 'Start monitor', + prompt: 'Watch for changes', + subagent_type: 'monitor', + }; + + const invocation = ( + agentTool as AgentToolWithProtectedMethods + ).createInvocation(params); + (invocation as unknown as { setCallId: (id: string) => void }).setCallId( + 'call-xyz-789', + ); + await invocation.execute(); + + expect(mockRegistry.register).toHaveBeenCalledWith( + expect.objectContaining({ toolUseId: 'call-xyz-789' }), + ); + }); + }); }); describe('resolveSubagentApprovalMode', () => { diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index 2ff98baf3..b85694df6 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { randomUUID } from 'node:crypto'; import { BaseDeclarativeTool, BaseToolInvocation, Kind } from '../tools.js'; import { ToolNames, ToolDisplayNames } from '../tool-names.js'; import type { @@ -59,6 +60,7 @@ export interface AgentParams { description: string; prompt: string; subagent_type?: string; + run_in_background?: boolean; } const debugLogger = createDebugLogger('AGENT'); @@ -185,6 +187,11 @@ export class AgentTool extends BaseDeclarativeTool { type: 'string', description: 'The type of specialized agent to use for this task', }, + run_in_background: { + type: 'boolean', + description: + 'Set to true to run this agent in the background. You will be notified when it completes.', + }, }, required: ['description', 'prompt'], additionalProperties: false, @@ -273,6 +280,7 @@ Usage notes: - Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent - If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement. - 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. Example usage: @@ -384,6 +392,7 @@ class AgentToolInvocation extends BaseToolInvocation { readonly eventEmitter: AgentEventEmitter = new AgentEventEmitter(); private currentDisplay: AgentResultDisplay | null = null; private currentToolCalls: AgentResultDisplay['toolCalls'] = []; + private callId?: string; constructor( private readonly config: Config, @@ -393,6 +402,11 @@ class AgentToolInvocation extends BaseToolInvocation { super(params); } + // Background agents carry the tool-use id through to completion notifications. + setCallId(callId: string): void { + this.callId = callId; + } + /** * Updates the current display state and calls updateOutput if provided */ @@ -653,10 +667,12 @@ class AgentToolInvocation extends BaseToolInvocation { 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. + // (including `agent` and cron tools) so the fork's system prompt, + // tools, and history exactly match the parent's and share its + // DashScope cache prefix. A fork is a context-sharing extension of + // the parent, not an isolated subagent, so the general subagent + // exclusion list does not apply. Recursive forks are blocked by the + // ALS-based `isInForkExecution()` guard. const parentToolDecls: FunctionDeclaration[] = ( generationConfig.tools as Array<{ @@ -695,6 +711,69 @@ class AgentToolInvocation extends BaseToolInvocation { return { subagent, taskPrompt }; } + // Runs the SubagentStop hook after execution. On a blocking decision, feeds the + // reason back and re-executes — up to 5 iterations to defend against a + // misconfigured hook looping forever. + private async runSubagentStopHookLoop( + subagent: AgentHeadless, + opts: { + agentId: string; + agentType: string; + resolvedMode: PermissionMode; + signal?: AbortSignal; + }, + ): Promise { + const { agentId, agentType, resolvedMode, signal } = opts; + const hookSystem = this.config.getHookSystem(); + if (!hookSystem) return; + + const transcriptPath = this.config.getTranscriptPath(); + let stopHookActive = false; + const maxIterations = 5; + + for (let i = 0; i < maxIterations; i++) { + 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() + ) { + return; + } + + stopHookActive = true; + const continueContext = new ContextState(); + continueContext.set( + 'task_prompt', + typedStopOutput.getEffectiveReason(), + ); + await subagent.execute(continueContext, signal); + + if (signal?.aborted) return; + } catch (hookError) { + debugLogger.warn( + `[Agent] SubagentStop hook failed, allowing stop: ${hookError}`, + ); + return; + } + } + + debugLogger.warn( + `[Agent] SubagentStop hook reached maximum iterations (${maxIterations}), forcing stop`, + ); + } + /** * Runs a subagent with start/stop hook lifecycle, updating the display * as execution progresses. @@ -738,69 +817,13 @@ class AgentToolInvocation extends BaseToolInvocation { // Execute the subagent (blocking) await subagent.execute(contextState, signal); - // Fire SubagentStop hook after execution and handle block decisions if (hookSystem && !signal?.aborted) { - const transcriptPath = this.config.getTranscriptPath(); - let stopHookActive = false; - - // Loop to handle "block" decisions (prevent subagent from stopping) - let continueExecution = true; - let iterationCount = 0; - const maxIterations = 5; // Prevent infinite loops from hook misconfigurations - - while (continueExecution) { - iterationCount++; - - // Safety check to prevent infinite loops - if (iterationCount >= maxIterations) { - debugLogger.warn( - `[TaskTool] SubagentStop hook reached maximum iterations (${maxIterations}), forcing stop to prevent infinite loop`, - ); - continueExecution = false; - break; - } - - try { - const stopHookOutput = await hookSystem.fireSubagentStopEvent( - agentId, - agentType, - transcriptPath, - subagent.getFinalText(), - stopHookActive, - resolvedMode, - signal, - ); - - const typedStopOutput = stopHookOutput as - | StopHookOutput - | undefined; - - if ( - typedStopOutput?.isBlockingDecision() || - typedStopOutput?.shouldStopExecution() - ) { - // Feed the reason back to the subagent and continue execution - const continueReason = typedStopOutput.getEffectiveReason(); - stopHookActive = true; - - const continueContext = new ContextState(); - continueContext.set('task_prompt', continueReason); - await subagent.execute(continueContext, signal); - - if (signal?.aborted) { - continueExecution = false; - } - // Loop continues to re-check SubagentStop hook - } else { - continueExecution = false; - } - } catch (hookError) { - debugLogger.warn( - `[TaskTool] SubagentStop hook failed, allowing stop: ${hookError}`, - ); - continueExecution = false; - } - } + await this.runSubagentStopHookLoop(subagent, { + agentId, + agentType, + resolvedMode, + signal, + }); } // Get the results @@ -941,14 +964,141 @@ class AgentToolInvocation extends BaseToolInvocation { const contextState = new ContextState(); contextState.set('task_prompt', taskPrompt); + // Date.now() alone collides when two parallel background agents of the + // same type land in the same ms; the registry is keyed by agentId. + const agentIdSuffix = this.callId ?? randomUUID().slice(0, 8); const hookOpts = { - agentId: `${subagentConfig.name}-${Date.now()}`, + agentId: `${subagentConfig.name}-${agentIdSuffix}`, agentType: this.params.subagent_type || subagentConfig.name, resolvedMode, signal, updateOutput, }; + // ── Background (async) execution path ────────────────────── + // OR the tool parameter with the agent definition's background flag. + const shouldRunInBackground = + this.params.run_in_background === true || + subagentConfig.background === true; + + if (shouldRunInBackground) { + // Fire SubagentStart hook before background launch + const hookSystem = this.config.getHookSystem(); + if (hookSystem) { + try { + const startHookOutput = await hookSystem.fireSubagentStartEvent( + hookOpts.agentId, + hookOpts.agentType, + resolvedMode, + signal, + ); + const additionalContext = startHookOutput?.getAdditionalContext(); + if (additionalContext) { + contextState.set('hook_context', additionalContext); + } + } catch (hookError) { + debugLogger.warn( + `[Agent] SubagentStart hook failed, continuing execution: ${hookError}`, + ); + } + } + + // Create an independent AbortController — background agents + // survive ESC cancellation of the parent's current turn. + const bgAbortController = new AbortController(); + + // Background agents have no UI, so interactive permission prompts must be + // auto-denied rather than auto-approved (YOLO). PermissionRequest hooks + // still run and can override. Use Object.create so the resolved approval + // mode override (e.g. subagent-level `approvalMode: auto-edit`) is preserved. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const bgConfig = Object.create(agentConfig) as any; + bgConfig.getShouldAvoidPermissionPrompts = () => true; + + // Register in the background task registry only AFTER init succeeds — if + // construction throws, a pre-registered phantom 'running' entry would hang + // the non-interactive hold-back loop forever. + let bgSubagent: AgentHeadless; + if (isFork) { + const fork = await this.createForkSubagent(bgConfig as Config); + bgSubagent = fork.subagent; + } else { + bgSubagent = await this.subagentManager.createAgentHeadless( + subagentConfig, + bgConfig as Config, + ); + } + + const registry = this.config.getBackgroundTaskRegistry(); + registry.register({ + agentId: hookOpts.agentId, + description: this.params.description, + subagentType: subagentConfig.name, + status: 'running', + startTime: Date.now(), + abortController: bgAbortController, + toolUseId: this.callId, + }); + + const getCompletionStats = () => { + const summary = bgSubagent.getExecutionSummary(); + return { + totalTokens: summary.totalTokens, + toolUses: summary.totalToolCalls, + durationMs: summary.totalDurationMs, + }; + }; + + // Fire-and-forget: start the subagent without blocking the parent. + // For forks, wrap the body in runInForkContext so the recursive-fork + // guard in execute() fires if the fork child's model calls `agent` + // again — otherwise background forks bypass the ALS marker and can + // spawn nested implicit forks. + const bgBody = async () => { + try { + await bgSubagent.execute(contextState, bgAbortController.signal); + + if (hookSystem && !bgAbortController.signal.aborted) { + await this.runSubagentStopHookLoop(bgSubagent, { + agentId: hookOpts.agentId, + agentType: hookOpts.agentType, + resolvedMode, + signal: bgAbortController.signal, + }); + } + + // Report terminate mode: only GOAL counts as success. ERROR, + // MAX_TURNS, and TIMEOUT are surfaced as failures so the parent + // model (and the UI) don't treat incomplete runs as completed. + const terminateMode = bgSubagent.getTerminateMode(); + const finalText = bgSubagent.getFinalText(); + const completionStats = getCompletionStats(); + if (terminateMode === AgentTerminateMode.GOAL) { + registry.complete(hookOpts.agentId, finalText, completionStats); + } else { + registry.fail( + hookOpts.agentId, + finalText || `Agent terminated with mode: ${terminateMode}`, + completionStats, + ); + } + } catch (error) { + const errorMsg = + error instanceof Error ? error.message : String(error); + debugLogger.error(`[Agent] Background agent failed: ${errorMsg}`); + + registry.fail(hookOpts.agentId, errorMsg, getCompletionStats()); + } + }; + void (isFork ? runInForkContext(bgBody) : bgBody()); + + this.updateDisplay({ status: 'background' as const }, updateOutput); + return { + llmContent: `Background agent launched: "${this.params.description}" (ID: ${hookOpts.agentId}). You will be notified when it completes.`, + returnDisplay: this.currentDisplay!, + }; + } + if (isFork) { // Background fork execution. Run under an AsyncLocalStorage frame so // nested `agent` tool calls by the fork's model can be detected. diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 9898b8610..b6847235c 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -495,7 +495,7 @@ export interface AgentResultDisplay { subagentColor?: string; taskDescription: string; taskPrompt: string; - status: 'running' | 'completed' | 'failed' | 'cancelled'; + status: 'running' | 'completed' | 'failed' | 'cancelled' | 'background'; terminateReason?: string; result?: string; executionSummary?: AgentStatsSummary;