mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-10 03:59:33 +00:00
feat: background subagents with headless and SDK support (#3076)
* feat(core): add run_in_background support for Agent tool Enable sub-agents to run asynchronously via `run_in_background: true` parameter. Background agents execute independently from the parent, which receives an immediate launch confirmation and continues working. A notification is injected into the parent conversation when the background agent completes. Key changes: - BackgroundTaskRegistry tracks lifecycle of background agents - Agent tool gains async execution path with fire-and-forget semantics - Background agents use YOLO approval mode to prevent deadlock - Independent AbortControllers survive parent ESC cancellation - CLI bridges notifications via useMessageQueue for between-turn delivery - State race guards prevent complete/fail after cancellation - Session cleanup aborts all running background agents * feat(background): improve notification formatting and UI handling - Add prefix/separator protocol to distinguish background notifications from user input - Show concise summary in UI while sending full details to LLM - Add 'notification' history item type with specialized display - Add 'background' agent status for background-running agents - Prevent notifications from polluting prompt history (up-arrow) - Truncate long descriptions in display text This improves the UX for background agents by showing cleaner, more concise notifications while preserving full context for the LLM. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(background): reject run_in_background in non-interactive mode Headless mode skips AppContainer, so the notification callback is never registered and background agent results would be silently dropped. Return an error prompting the model to retry without run_in_background. * refactor(background): replace prefix/separator protocol with typed notification queue Replace the stringly-typed \x00__BG_NOTIFY__\x00 prefix/separator encoding with a typed notification path using SendMessageType.Notification. - Add SendMessageType.Notification to the enum - Change BackgroundNotificationCallback to emit (displayText, modelText) - Move notification queue from AppContainer into useGeminiStream (mirrors the cron queue pattern): register on registry, queue structured items, drain on idle via submitQuery - prepareQueryForGemini short-circuits for Notification type (skips slash commands, shell mode, @-commands, prompt history logging) - Remove BACKGROUND_NOTIFICATION_PREFIX/SEPARATOR constants * refactor(background): move abortAll to Config.shutdown Background agent cleanup belongs in Config.shutdown() alongside other resource teardown (skillManager, toolRegistry, arenaRuntime), not in AppContainer's registerCleanup. This also ensures headless mode gets cleanup for free. * fix(background): persist notification items for session resume Background agent notifications were missing after session resume because they were never recorded in the chat history. The model text was absent from the API history and the display item was lost. - Add recordNotification() to ChatRecordingService — stores as user-role message with subtype 'notification' and displayText payload - Thread notificationDisplayText through submitQuery → sendMessageStream - Restore as HistoryItemNotification in resumeHistoryUtils * fix(background): replace YOLO with deny-by-default for background agents Background agents were using YOLO approval mode which auto-approves all tool calls — too permissive. Replace with shouldAvoidPermissionPrompts which auto-denies tool calls that need interactive approval, matching claw-code's approach. The permission flow for background agents is now: 1. L3/L4 permission rules (allow/deny) — same as foreground 2. Approval mode overrides (AUTO_EDIT for edits) — same as foreground 3. PermissionRequest hooks — can override the denial 4. Auto-deny — if no hook decided, deny because prompts are unavailable * fix(background): add missing getBackgroundTaskRegistry mock in useGeminiStream tests * refactor(core): move fork subagent params from execute() to construction time Identity-shaping fork inputs (parent history, generationConfig, tool decls, env-skip flag) were threaded through `AgentHeadless.execute()`'s options bag and re-passed by the SubagentStop hook retry loop. They belong on the agent's construction-time configs, not its per-invocation options. - PromptConfig gains `renderedSystemPrompt` (verbatim, bypasses templating and userMemory injection) and drops the `systemPrompt`/`initialMessages` XOR so fork can carry both. createChat skips env bootstrap when `initialMessages` is non-empty. - AgentHeadless.execute() shrinks to (context, signal?). Fork dispatch in agent.ts builds synthetic PromptConfig/ModelConfig/ToolConfig from the parent's cache-safe params and calls AgentHeadless.create directly (bypassing SubagentManager). Parent's tool decls flow through verbatim including the `agent` tool itself for cache parity. - Recursive-fork prevention switches from fork-side tool stripping to a runtime guard. The previous `isInForkChild(history)` helper was dead code (it scanned the main GeminiClient's history, not the fork child's chat). Replaced with `isInForkExecution()` backed by AsyncLocalStorage: the fork's background execution runs inside `runInForkContext`, and the ALS frame propagates through the standard async chain into nested AgentTool.execute() calls where the guard fires. * refactor(core): move agent tool files into dedicated tools/agent/ directory Move agent.ts, agent.test.ts, and fork-subagent.ts under tools/agent/ and update all import paths accordingly. * refactor(core): remove dead temp and top_p fields from ModelConfig These fields were never populated from subagent frontmatter and served no purpose in the fork path either. The ModelConfig interface retains only the actively-used model field. * refactor(core): read parent generation config directly instead of getCacheSafeParams Fork subagent now reads system instruction and tool declarations from the live GeminiChat via getGenerationConfig() instead of the global getCacheSafeParams() snapshot. This removes the cross-module coupling between the agent tool and the followup infrastructure. * fix(core): prevent duplicate tool declarations when toolConfig has only inline decls prepareTools() treated asStrings.length === 0 as "add all registry tools", which is correct when no tools are specified at all, but wrong when the caller provides only inline FunctionDeclaration[] (no string names). The fork path passes parent tool declarations as inline decls for cache parity, so prepareTools was adding the full registry set on top — duplicating every non-excluded tool. Add onlyInlineDecls.length === 0 to the condition so that pure-inline toolConfigs bypass the registry entirely. * feat(core): support agent-level `background: true` in frontmatter Subagent definitions can now declare `background: true` in their YAML frontmatter to always run as background tasks. This is OR'd with the `run_in_background` tool parameter — useful for monitors, watchers, and proactive agents so the LLM doesn't need to remember to set the flag. * fix(core): address background subagent lifecycle gaps - Inherit bgConfig from agentConfig so the resolved approval mode is preserved for background agents (foreground would run AUTO_EDIT but background fell back to DEFAULT, which combined with shouldAvoid- PermissionPrompts would auto-deny every permission request). - Honor SubagentStop blocking decisions in background runs by looping on hook output up to 5 iterations, matching runSubagentWithHooks. - Check terminate mode before reporting completion; non-GOAL modes (ERROR, MAX_TURNS, TIMEOUT) are now reported as failures instead of emitting a success notification for an incomplete run. - Exclude SendMessageType.Notification from the UserPromptSubmit hook guard so background completion messages are not rewritten or blocked as if they were user input. * feat(cli): headless support and SDK task events for background agents (#3379) * feat(cli): unify notification queue for cron and background agents Migrate cron from its own queue (cronQueueRef / cronQueue) to the shared notification queue used by background agents. Both producers now push the same item shape { displayText, modelText, sendMessageType } and a single drain effect / helper processes them in FIFO order. Cron fires render as HistoryItemNotification (● prefix) instead of HistoryItemUser (> prefix), with a "Cron: <prompt>" display label. Records use subtype 'cron' for clean resume and analytics separation. Lift the non-interactive rejection for background agents. Register a notification callback in nonInteractiveCli.ts with a terminal hold-back phase (100ms poll) that keeps the process alive until all background agents complete and their notifications are processed. * feat(cli): emit SDK task events for background subagents Emit `task_started` when a background agent registers and `task_notification` when it completes, fails, or is cancelled, so headless/SDK consumers can track lifecycle without parsing display text. Model-facing text is now structured XML with status, summary, truncated result, and usage stats. Completion stats (tokens, tool uses, duration) are captured from the subagent and included in both the SDK payload and the model XML. * fix: address codex review issues for background subagents - Background subagents now inherit the resolved approval mode from agentConfig instead of the raw session config, so a subagent with `approvalMode: auto-edit` (or execution in a trusted folder) keeps that override when it runs asynchronously. - Non-interactive cron drains are single-flight: concurrent cron fires now await the same in-flight drain, and the cron-done check gates on it, preventing the final result from being emitted while a cron turn is still streaming. - Background forks go through createForkSubagent so they retain the parent's rendered system prompt and inherited history instead of degrading to a plain FORK_AGENT. * fix(cli): restore cancellation, approval, and error paths in queued drain - Hold-back loop now reacts to SIGINT/SIGTERM: when the main abort signal fires it calls registry.abortAll() so background agents with their own AbortControllers stop promptly instead of pinning the process open. - Queued-turn tool execution forwards the stream-json approval update callback (onToolCallsUpdate) so permission-gated tools inside a background-notification follow-up emit can_use_tool requests. - Queued-turn stream loop mirrors the main loop's text-mode handling of GeminiEventType.Error, writing to stderr and throwing so provider errors produce a non-zero exit code instead of silently succeeding. - Interactive cron prompts go through the normal slash/@-command/shell preprocessing again; only Notification messages skip that path. * fix(cli): skip duplicate user-message item for cron prompts Cron prompts already render as a `● Cron: …` notification via the queue drain, so adding them again as a `USER` history item produced a duplicate `> …` line. * fix(cli): honor SIGINT/SIGTERM during cron scheduler wait The non-interactive cron phase awaits a Promise that resolves only when scheduler.size reaches 0 and no drain is in flight. Recurring cron jobs never drop the scheduler size to 0 on their own, so the previous abort handling (added to the hold-back loop) was unreachable — the process hung indefinitely after SIGINT/SIGTERM. Attach an abort listener inside the promise so abort stops the scheduler and resolves immediately, allowing the hold-back loop to run and the process to exit cleanly. * feat(core): propagate tool-use id through background agent notifications Plumb the scheduler's callId into AgentToolInvocation via an optional setCallId hook on the invocation, detected structurally in buildInvocation. The agent tool forwards it as toolUseId on the BackgroundTaskRegistry entry so completion notifications can carry a <tool-use-id> tag and SDK task_started / task_notification events can emit tool_use_id — letting consumers correlate background completions back to the original Agent tool-use that spawned them. * fix(cli): drain single-flight race kept task_notification from emitting drainLocalQueue wrapped its body in an async IIFE and cleared the promise reference via finally. When the queue is empty the IIFE has no awaits, so its finally runs synchronously as part of the RHS of the assignment `drainPromise = (async () => {...})()` — clearing drainPromise BEFORE the outer assignment overwrites it with the resolved promise. The reference then stayed stuck on that fulfilled promise forever, so later calls short-circuited through `if (drainPromise) return drainPromise` and never processed queued notifications. Symptom: in headless `--output-format json` (and `stream-json`), task_started emitted but task_notification never did, even after the background agent completed. The process sat in the hold-back loop until SIGTERM. Fix: move the null-clearing out of the async body into an outer `.finally()` on the returned promise. `.finally()` runs as a microtask after the current synchronous block, so it clears the latest drainPromise reference instead of the pre-assignment null. * fix(cli): append newline to text-mode emitResult so zsh PROMPT_SP doesn't erase the line Headless text mode wrote `resultMessage.result` without a trailing newline. In a TTY, zsh themes that use PROMPT_SP (powerlevel10k, agnoster, …) detect the missing `\n` and emit `\r\033[K` before drawing the next prompt, which wipes the final line off the screen. Pipe-captured output was unaffected, so the bug only surfaced for interactive shell users — most visibly in the background-agent flow where the drain-loop's final assistant message is the *only* stdout write in text mode. Append `\n` to both the success (stdout) and error (stderr) writes. * docs(skill): tighten worked-example blurb in structured-debugging Mirror the simplified blurb from .claude/skills/structured-debugging/SKILL.md (knowledge repo). Drops the round-by-round narrative; keeps the contradiction + two lessons. * docs(skill): mirror SKILL.md improvements (reframing failure mode, generalized path, value-logging guidance) Mirror of knowledge repo commit 38eb28d into the qwen-code .qwen/skills copy. * docs(skill): mirror worked example into .qwen/skills/structured-debugging/ Mirrors knowledge/.claude/skills/structured-debugging/examples/ headless-bg-agent-empty-stdout.md so the .qwen copy of the skill links resolve. * docs(skill): mirror generalized side-note path guidance * fix(cli): harden headless cron and background-agent failure paths Three regressions surfaced by Codex review of feat/background-subagent: - Cron drain rejections were dropped by a bare `void`, so a failing queued turn left the outer Promise unresolved and hung the run. Route drain failures through the Promise's reject so they propagate to the outer catch. - The background-agent registry entry was inserted before `createForkSubagent()` / `createAgentHeadless()` was awaited. Failed init returned an error from the tool call but left a phantom `running` entry, and the headless hold-back loop (`registry.getRunning()`) waited forever. Register only after init succeeds. - SIGINT/SIGTERM during the hold-back phase aborted background tasks, then fell through to `emitResult({ isError: false })`, so a cancelled `qwen -p ...` exited 0 with the prior assistant text. Route through `handleCancellationError()` so cancellation exits non-zero, matching the main turn loop. * test(cli): update stdout/stderr assertions for trailing newline `feadf052f` appended `\n` to text-mode `emitResult` output, but the nonInteractiveCli tests still asserted the pre-change strings. Update the 11 affected assertions to expect the trailing newline. * fix: address review comments on background-agent notifications Four additional issues from the PR review that the prior regression-fix commit didn't cover: - Escape XML metacharacters when interpolating `description`, `result`, `error`, `agentId`, `toolUseId`, and `status` into the task-notification envelope. Subagent output (which itself may carry untrusted tool output, fetched HTML, or another agent's notification) could contain `</result>` or `</task-notification>` and forge sibling tags the parent model would treat as trusted metadata. Truncate result text *before* escaping so the truncation never slices through an entity like `&`. - Emit the terminal notification from `cancel()` and `abortAll()`. The fire-and-forget `complete()`/`fail()` from the subagent task is guarded by `status !== 'running'` and was no-op'd after cancellation, so SDK consumers saw `task_started` with no matching `task_notification`, breaking the contract this PR establishes. Updated two race-guard tests that asserted the old behavior. - Call `adapter.finalizeAssistantMessage()` before the abort-triggered early return inside `drainOneItem`'s stream loop. Without it, `startAssistantMessage()` had already been called, so stream-json mode left `message_start` unpaired. - Enforce `config.getMaxSessionTurns()` in `drainOneItem` for symmetry with the main turn loop. Cron fires and notification replies otherwise bypass the budget cap in headless runs. * fix: address codex review comments for background subagents - Wrap background fork execute() in runInForkContext so the recursive-fork guard (AsyncLocalStorage-based) fires when a background fork's child model calls `agent` again. Previously only the foreground fork path was wrapped, so background forks could spawn nested implicit forks. - Emit queued terminal task_notifications on SIGINT/SIGTERM before handleCancellationError exits. abortAll() enqueues cancellation notifications via the registry callback, but the process was exiting before the drain loop had a chance to flush them — leaving stream-json consumers that already saw task_started without a matching terminal task_notification. Extracted the SDK-emit block into a shared emitNotificationToSdk helper reused by the normal drain and the cancellation flush. - Skip notification/cron subtypes in ACP HistoryReplayer. These records are persisted as type: 'user' so the model's chat history keeps them for continuity, but they were never user input — replaying them leaked raw <task-notification> XML (and cron prompts) back into the ACP session as if the user typed them. * test(cli): sync JsonOutputAdapter text-mode assertions with trailing newline Commit0da1182b7appended a newline to text-mode emitResult output (zsh PROMPT_SP fix) and updated the nonInteractiveCli tests, but four assertions in JsonOutputAdapter.test.ts were missed. Update them to expect the trailing newline so CI passes. * refactor: simplify background subagent plumbing - Extract the SubagentStop hook blocking-decision loop into a runSubagentStopHookLoop helper so the foreground and background paths no longer duplicate the iteration/abort/log scaffolding. - Unify BackgroundTaskRegistry.abortAll to delegate to cancel, removing copy-pasted abort/notification bookkeeping. - Drop the unused findByName and BackgroundAgentEntry.name field. - In nonInteractiveCli drain, hoist inputFormat and toolCallUpdateCallback out of the inner tool loop, and drop the unreachable try/catch around the readonly registry. - Trim boilerplate doc/narration comments while keeping load-bearing WHY comments. * fix: address codex review comments for background subagents - Use tool callId (or short random suffix) instead of Date.now() for background agentIds; avoids registry collisions when parallel same-type agents launch in the same millisecond. - Reset loopDetector and lastPromptId for Notification turns so a prior turn's loop count doesn't trip LoopDetected on the notification response. - Replay notification/cron displayText in ACP HistoryReplayer so the assistant reply has an antecedent in resumed transcripts. --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
2080686a62
commit
7e83c08062
26 changed files with 1708 additions and 244 deletions
|
|
@ -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/<project>-<issue>.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_.
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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<void> | null = null;
|
||||
const drainLocalQueue = (): Promise<void> => {
|
||||
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<void>((resolve) => {
|
||||
const cronQueue: string[] = [];
|
||||
let processing = false;
|
||||
|
||||
const checkDone = () => {
|
||||
if (scheduler.size === 0 && !processing) {
|
||||
if (scheduler && scheduler.size > 0) {
|
||||
await new Promise<void>((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);
|
||||
|
|
|
|||
|
|
@ -98,6 +98,9 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
|||
{itemForDisplay.type === 'user' && (
|
||||
<UserMessage text={itemForDisplay.text} />
|
||||
)}
|
||||
{itemForDisplay.type === 'notification' && (
|
||||
<InfoMessage text={itemForDisplay.text} />
|
||||
)}
|
||||
{itemForDisplay.type === 'user_shell' && (
|
||||
<UserShellMessage text={itemForDisplay.text} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<string[]>([]);
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
287
packages/core/src/agents/background-tasks.test.ts
Normal file
287
packages/core/src/agents/background-tasks.test.ts
Normal file
|
|
@ -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('<status>cancelled</status>');
|
||||
});
|
||||
|
||||
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('<status>cancelled</status>');
|
||||
});
|
||||
|
||||
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('<tool-use-id>call-abc-123</tool-use-id>');
|
||||
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('<tool-use-id>');
|
||||
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 </result> & </task-notification>',
|
||||
status: 'running',
|
||||
startTime: Date.now(),
|
||||
abortController: new AbortController(),
|
||||
});
|
||||
|
||||
registry.complete('test-1', 'here is <b>bold</b> & </task-notification>');
|
||||
|
||||
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('&');
|
||||
});
|
||||
});
|
||||
248
packages/core/src/agents/background-tasks.ts
Normal file
248
packages/core/src/agents/background-tasks.ts
Normal file
|
|
@ -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 `</task-notification>` — without escaping, a subagent
|
||||
// summarizing HTML or another agent's notification could close the
|
||||
// envelope early and forge sibling tags (e.g. a faked <status>) that the
|
||||
// parent model would treat as trusted metadata.
|
||||
function escapeXml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.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<string, BackgroundAgentEntry>();
|
||||
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[] = [
|
||||
'<task-notification>',
|
||||
`<task-id>${escapeXml(entry.agentId)}</task-id>`,
|
||||
];
|
||||
if (entry.toolUseId) {
|
||||
xmlParts.push(`<tool-use-id>${escapeXml(entry.toolUseId)}</tool-use-id>`);
|
||||
}
|
||||
xmlParts.push(
|
||||
`<status>${escapeXml(entry.status)}</status>`,
|
||||
`<summary>Agent "${escapeXml(entry.description)}" ${statusText}.</summary>`,
|
||||
);
|
||||
if (rawResult) {
|
||||
xmlParts.push(`<result>${escapeXml(rawResult)}</result>`);
|
||||
}
|
||||
if (entry.error) {
|
||||
xmlParts.push(`<result>Error: ${escapeXml(entry.error)}</result>`);
|
||||
}
|
||||
if (entry.stats) {
|
||||
xmlParts.push(
|
||||
'<usage>',
|
||||
`<total_tokens>${entry.stats.totalTokens}</total_tokens>`,
|
||||
`<tool_uses>${entry.stats.toolUses}</tool_uses>`,
|
||||
`<duration_ms>${entry.stats.durationMs}</duration_ms>`,
|
||||
'</usage>',
|
||||
);
|
||||
}
|
||||
xmlParts.push('</task-notification>');
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -16,3 +16,4 @@
|
|||
export * from './backends/index.js';
|
||||
export * from './arena/index.js';
|
||||
export * from './runtime/index.js';
|
||||
export * from './background-tasks.js';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -610,6 +610,7 @@ export class CoreToolScheduler {
|
|||
const invocationOrError = this.buildInvocation(
|
||||
call.tool,
|
||||
args as Record<string, unknown>,
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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<RunConfig>,
|
||||
color,
|
||||
level,
|
||||
...(background ? { background } : {}),
|
||||
};
|
||||
|
||||
// Validate the parsed configuration
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<typeof vi.fn>;
|
||||
complete: ReturnType<typeof vi.fn>;
|
||||
fail: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
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<string, unknown>)['isInteractive'] = vi
|
||||
.fn()
|
||||
.mockReturnValue(true);
|
||||
(config as unknown as Record<string, unknown>)[
|
||||
'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<typeof vi.fn>,
|
||||
).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', () => {
|
||||
|
|
|
|||
|
|
@ -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<AgentParams, ToolResult> {
|
|||
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<AgentParams, ToolResult> {
|
|||
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<AgentParams, ToolResult> {
|
|||
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<AgentParams, ToolResult> {
|
|||
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<AgentParams, ToolResult> {
|
|||
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<void> {
|
||||
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<AgentParams, ToolResult> {
|
|||
// 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<AgentParams, ToolResult> {
|
|||
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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue