From d9ae7b90320b6324281350350ff8567ad40e3faf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 15:00:18 +0000 Subject: [PATCH] [EPIC-Y][LOU-Y4.2] Background sub-agents are cancelled or awaited when the lead run ends Add ExecuteOptions.onRunEnd, called exactly once per execute()/stream() run in a finally (any finishReason, or the error). The subagent runtime wraps it: background tasks still queued or running when the run ends are cancelled (or awaited with awaitBackgroundOnFinish), the run resolves once their runs have stopped, and result.backgroundTasks reports them. createAgent({ subagentOptions }) forwards maxConcurrent and awaitBackgroundOnFinish without mutating the caller's subagents value. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016bY29ibQgv3MDdjAbUT4YR --- CHANGELOG.md | 4 +- docs/sub-agents.md | 62 +++++++++++---- llms-full.txt | 62 +++++++++++---- src/createAgent.ts | 11 ++- src/execution/AgentExecutor.ts | 32 +++++++- src/execution/runEnd.test.ts | 76 +++++++++++++++++++ src/subagents/backgroundSubagents.test.ts | 92 ++++++++++++++++++++++- src/subagents/backgroundTasks.ts | 25 +++++- src/subagents/withSubagents.ts | 53 ++++++++++--- 9 files changed, 375 insertions(+), 42 deletions(-) create mode 100644 src/execution/runEnd.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ce7ce7a09..0ddaad700 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - 2026-09-28 ### Added +- Background sub-agents end with the lead run (LOU-Y4.2): when a run ends (any `finishReason`, or an error), its background sub-agents still queued or running are cancelled, and the run resolves once their runs have stopped, so none of their events reaches `onEvent` afterwards. `withSubagentOptions(subagents, { awaitBackgroundOnFinish: true })` waits for them instead (an aborted or failed lead still cancels them). The new `result.backgroundTasks` lists the run's background tasks as `{ taskId, agent, status, elapsedMs, ... }` (absent when it started none). `createAgent({ subagentOptions: { maxConcurrent, awaitBackgroundOnFinish } })` sets the same options (over any attached with `withSubagentOptions()`, without changing the `subagents` value). The executor hook behind it is public: `ExecuteOptions.onRunEnd({ result } | { error })` is called exactly once per `execute()` / `stream()` run, however it ends; an error it throws rejects the run. See docs/sub-agents.md. - Durable sessions (LOU-W9): `agent.session({ id, store, checkpointStore })` (or a `store` that carries both, `{ sessions, checkpoints }`, such as a `SqliteStore`) runs each turn with `sessionId: '.turn-'` and that `CheckpointStore`, so it is checkpointed after every model response and tool result. `session.resume()` finishes a turn interrupted by a crash, a failed checkpoint write or a `PropagatingToolError` (also in a new process) without re-running recorded tool calls or model responses, adds it to the transcript as `send()` would and returns its result (`null` when nothing is pending); `session.pending()` reports an unfinished turn and `session.discardPending()` drops it. `send()` / `stream()` resume a pending turn before sending the new message. A turn paused for approval stays in its checkpoint until it finishes: `resume()` / `send()` throw `SessionAwaitingApprovalError`, and `agent.approvals.resolve()` continues it in the session. Sessions without a checkpoint store are unchanged. See docs/sessions.md. - Structured output (LOU-V4): `createAgent({ output: zodSchema })` (and `ExecuteOptions.output`) makes the final reply a JSON object validated by the schema. `send()`, `run.result` and `agent.approvals.resolve()` resolve with it as `result.object`, typed `z.output` from `send()` and `stream()` of `createAgent()` agents (`SimpleAgent`, `AgentRun` and `ExecutionResult` gain a type parameter that defaults to `unknown`); `result.text` keeps the raw JSON. The system prompt gets an `## Output format` section with the schema as JSON Schema, and each model call carries the new `GenerateOptions.responseFormat: { type: 'json', schema? }` hint, which the `ai`-SDK providers map to `experimental_output` JSON mode. The reply is parsed (a code fence is tolerated) and validated; when invalid, one repair step sends the model the issues (it counts against `maxSteps`), and a reply still invalid ends the run with the new `finishReason: 'output-invalid'`, no `object` and `outputError: { message, issues }`. `run.done` gains an optional `object` field (additive: the event schema version stays 1). See docs/structured-output.md. -- Background sub-agents (LOU-Y4): the `task` tool takes `background: true`, which starts the sub-agent and returns `{ taskId, status: 'running', agent }` at once. Agents with `subagents` also get `agent_status({ taskId? })` (`queued`/`running`/`done`/`failed`/`cancelled`/`awaiting-approval`, plus `elapsedMs`), `agent_await({ taskId | taskIds, timeoutMs? })` (the synchronous `task` result, or `status: 'timeout'`) and `agent_cancel({ taskId })`. `withSubagentOptions(subagents, { maxConcurrent })` bounds how many run at once per lead run (default 3; the rest queue). Background sub-agents inherit the lead run's runtime and `maxSubagentDepth`, and aborting the lead cancels them. Not yet (LOU-Y4.2): cancelling or awaiting them when the lead run finishes, and resuming one that paused for approval (it reports `awaiting-approval` with its `approvalId`). A user tool named `agent_status`, `agent_await` or `agent_cancel` now conflicts with `subagents`, like `task`. See docs/sub-agents.md. +- Background sub-agents (LOU-Y4): the `task` tool takes `background: true`, which starts the sub-agent and returns `{ taskId, status: 'running', agent }` at once. Agents with `subagents` also get `agent_status({ taskId? })` (`queued`/`running`/`done`/`failed`/`cancelled`/`awaiting-approval`, plus `elapsedMs`), `agent_await({ taskId | taskIds, timeoutMs? })` (the synchronous `task` result, or `status: 'timeout'`) and `agent_cancel({ taskId })`. `withSubagentOptions(subagents, { maxConcurrent })` bounds how many run at once per lead run (default 3; the rest queue). Background sub-agents inherit the lead run's runtime and `maxSubagentDepth`, and aborting the lead cancels them. Not yet: resuming one that paused for approval (it reports `awaiting-approval` with its `approvalId`). A user tool named `agent_status`, `agent_await` or `agent_cancel` now conflicts with `subagents`, like `task`. See docs/sub-agents.md. - React hook (LOU-D15): new `@loushy/build-ai-agent/react` subpath with `useLoushyAgent(source, options?)`. `source` is `{ agent, sessionId? }` (in process, via `agent.stream()` or `agent.session({ id }).stream()`) or `{ url, headers?, fetch? }` (POSTs `{ input }` and reads the SSE or NDJSON event stream). It returns `messages` (text and tool calls with status, args and result), `status` (`idle`/`streaming`/`awaiting-approval`/`error`), `pendingApproval`, `error`, `usage`, `lastEvent`, `send()`, `stop()` (aborts; unmount aborts too), `approve(note?)` and `reject(note?)` (via `agent.approvals.resolve()` in process, or `POST ${approvalsUrl}/${id}` remotely). The framework-neutral `reduceAgentEvents()` reducer and `parseEventStream()` parser are exported from the same subpath. `react` (`^18 || ^19`) is an optional peer dependency. See docs/react.md. - Summarizing compaction (LOU-W3): `summarizeStrategy({ model, protectedTokens?, prompt?, maxSummaryTokens? })` replaces the turns before the protected tail with one `[Conversation summary]` user message written by `model` (an `LLMProvider` or a `"provider/model"` spec), keeping system and pinned messages and each assistant turn together with its tool results; if the summary call fails it falls back to pruning and reports `error`. `twoPhaseStrategy()` (the recommended strategy) prunes first and summarizes only when still above the threshold. `pinMessage()` / `isPinned()` and the new optional `Message.metadata` (never sent to providers) mark messages no built-in strategy compacts. `CompactionStrategy.compact()` may now be async, so `compactMessages()` returns a Promise; `CompactionResult` / `CompactionInfo` gain `summary` and `error`, and `CompactionInput` gains `thresholdTokens` and `signal`. A strategy that throws no longer fails the run: the hook reports the `error` to `onCompaction`. See docs/compaction.md. - `createAgent({ retry, fallbackModels })` (LOU-V7.2): `retry` (`withRetry()` options, or `false`) retries failed model calls and defaults to `{ maxRetries: 2 }` for models given as `provider/model` strings (or picked from the environment); a `provider` instance is wrapped only when `retry` is set. `fallbackModels` are `provider/model` strings tried in order once the primary model's retries are used up (`withFallback([withRetry(primary), withRetry(fallback), ...])`). `agent.stream()`, `session.stream()` and `AgentExecutor.stream()` report them as two new typed events, `provider.retry` (`attempt`, `maxRetries`, `delayMs`, `error: { message, category? }`, `provider`) and `provider.fallback` (`from`, `to`, `error: { message }`), for any `withRetry()` / `withFallback()` provider. Additive: the event schema version stays 1. @@ -33,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - README revamped into a short front page (LOU-D52); the details it dropped moved to `docs/` (new pages: tools, approvals, providers, cli, flows, guardrails, utilities). ### Changed +- Background sub-agents still running when the lead run ends are now cancelled (LOU-Y4.2) instead of running to completion on their own; a lead run that pauses for approval cancels them too (the resumed run starts with none). **Migration:** to keep them, have the lead collect them with `agent_await` before its final answer, or set `subagentOptions: { awaitBackgroundOnFinish: true }`. - Heavy dependencies are optional peers (LOU-D40): `dockerode` (Docker sandboxing, `SubprocessSandbox`), `@modelcontextprotocol/sdk` (`serveMcp`, `loushy mcp`, MCP clients) and `prompts` (the interactive questions of `loushy init`) moved from `dependencies` to `peerDependencies`, marked optional in `peerDependenciesMeta`. They were already loaded lazily; now a project that uses none of them does not install them. Using one without its package fails that call with `MissingPeerDependencyError`, which now also carries `feature` and names the exact command (for example `npm install dockerode@^5.0.1`). `loushy doctor` lists the three with what they enable (a missing `dockerode` fails the check when the agent spec uses a sandboxed tool), and its version lookup no longer returns `undefined` for packages whose `exports` map hides `package.json`. **Migration:** install the peer if you use Docker sandboxing (`npm install dockerode`), MCP (`npm install @modelcontextprotocol/sdk`), or interactive `loushy init` (`npm install prompts`; `npm create loushy-agent` already does, and `loushy init --yes` needs nothing). See docs/installation.md. - The `http` tool no longer loads `undici` for default requests (LOU-D40): with TLS verification on (the default) it uses the runtime's global `fetch`; `undici` (still a regular dependency) is loaded only for `validateSSL: false`, whose per-request TLS dispatcher the global `fetch` cannot express. `yaml` stays a dependency (agent specs and skills need a YAML parser). - Retries no longer stack (LOU-V7.2): the built-in providers (`OpenAIProvider`, `AnthropicProvider`, `OllamaProvider`, `OpenRouterProvider`) now pass `maxRetries` to the `ai` SDK calls: `config.maxRetries`, or 2 (the `ai` SDK's default, so behaviour is unchanged) when unset. `createAgent()` builds the providers it resolves from strings with `maxRetries: 0` and retries in its `withRetry()` wrapper instead, so retries happen in one place (at most 3 calls by default, as before) and each one is observable. If you wrap a built-in provider in `withRetry()` / `resilientProvider()` yourself, build it with `maxRetries: 0`; note that a `maxRetries` you already set in a provider config is now honoured by the `ai` SDK. diff --git a/docs/sub-agents.md b/docs/sub-agents.md index 79757954d..d7b7c95d5 100644 --- a/docs/sub-agents.md +++ b/docs/sub-agents.md @@ -101,41 +101,77 @@ more tasks) while it runs. The lead then uses: At most `maxConcurrent` background sub-agents of one lead run run at once (default 3); further `background: true` calls get `status: 'queued'` and start -as slots free. Set it with `withSubagentOptions()`, which returns the same -`subagents` value: +as slots free. Set it with `subagentOptions` on `createAgent()`: ```ts -import { createAgent, withSubagentOptions } from '@loushy/build-ai-agent'; +import { createAgent } from '@loushy/build-ai-agent'; const researcher = createAgent({ provider, instructions: 'You research.', description: 'Finds and summarizes sources' }); const lead = createAgent({ provider, instructions: 'You coordinate research.', - subagents: withSubagentOptions({ researcher }, { maxConcurrent: 2 }), + subagents: { researcher }, + subagentOptions: { maxConcurrent: 2, awaitBackgroundOnFinish: true }, }); ``` +For `AgentExecutor.execute()`, attach the same options to the `subagents` +value with `withSubagentOptions(subagents, options)`, which returns it. On +`createAgent()`, `subagentOptions` override options attached that way. + A background sub-agent inherits from the lead run like a synchronous one (hooks, tracing, `onEvent`, usage roll-up), and `maxSubagentDepth` applies the same way. Aborting the lead's signal cancels its background sub-agents, queued or running. -Limits (follow-up LOU-Y4.2, which needs a run-end hook in the executor): +### When the lead run ends + +Background tasks belong to one run (one `execute()` / `send()` / `stream()`). +When that run ends, however it ends, the tasks still queued or running are +**cancelled** by default (their runs are aborted). With +`awaitBackgroundOnFinish: true` the run instead waits for them to finish +before it resolves (an aborted or failed lead run still cancels them). Either +way the run resolves only once their runs have stopped, so no sub-agent event +reaches `onEvent` after it; with `awaitBackgroundOnFinish` their events arrive +after the lead's own `finish` event. + +The result lists every background task of the run with its final status: + +```ts +import { createAgent } from '@loushy/build-ai-agent'; + +const researcher = createAgent({ provider, instructions: 'You research.', description: 'Finds and summarizes sources' }); +const lead = createAgent({ provider, instructions: 'You coordinate research.', subagents: { researcher } }); + +const result = await lead.send('Research three topics in the background.'); +for (const task of result.backgroundTasks ?? []) { + console.log(task.taskId, task.agent, task.status); // e.g. task_1 researcher cancelled +} +``` + +`result.backgroundTasks` holds `{ taskId, agent, status, elapsedMs, error?, +approvalId?, toolName? }` per task (the `agent_status` shape) and is absent +when the run started none. Use `agent_await` in the lead (the system prompt +tells it to) to get their answers into the conversation. + +The executor hook behind this is public: `ExecuteOptions.onRunEnd` is called +exactly once per run, with `{ result }` (whatever the `finishReason`) or +`{ error }`; the run settles after it returns. Background tasks are reported +on `result` before your `onRunEnd` sees it. + +Limits: -- Background tasks belong to one `execute()` call. When the lead's run - finishes, sub-agents still running are **not** cancelled or awaited yet: - they run to completion on their own, and their events still reach - `onEvent`. Have the lead collect them with `agent_await` before its final - answer (the system prompt tells it to), or abort the signal you passed to - the run to stop them. +- A lead run that pauses for approval (`finishReason: 'awaiting-approval'`) + ends there: its background tasks are cancelled (or awaited) like at any + other end, and the resumed run starts with none. The resumed run is a + separate run with no link to the paused one's tasks (it may even run in + another process), so they cannot be kept alive across the pause. - A background sub-agent that needs approval stops with `status: 'awaiting-approval'` (with the child's `approvalId` and `toolName`); its call does not run, and it cannot be resumed through the approval store. Run that task synchronously (`background: false`) when it may need approval. -- A lead that pauses for approval and is resumed starts with no background - tasks: those started before the pause cannot be observed afterwards. ## Dynamic catalogs diff --git a/llms-full.txt b/llms-full.txt index d046fd03e..1ad09fcaf 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -4677,41 +4677,77 @@ more tasks) while it runs. The lead then uses: At most `maxConcurrent` background sub-agents of one lead run run at once (default 3); further `background: true` calls get `status: 'queued'` and start -as slots free. Set it with `withSubagentOptions()`, which returns the same -`subagents` value: +as slots free. Set it with `subagentOptions` on `createAgent()`: ```ts -import { createAgent, withSubagentOptions } from '@loushy/build-ai-agent'; +import { createAgent } from '@loushy/build-ai-agent'; const researcher = createAgent({ provider, instructions: 'You research.', description: 'Finds and summarizes sources' }); const lead = createAgent({ provider, instructions: 'You coordinate research.', - subagents: withSubagentOptions({ researcher }, { maxConcurrent: 2 }), + subagents: { researcher }, + subagentOptions: { maxConcurrent: 2, awaitBackgroundOnFinish: true }, }); ``` +For `AgentExecutor.execute()`, attach the same options to the `subagents` +value with `withSubagentOptions(subagents, options)`, which returns it. On +`createAgent()`, `subagentOptions` override options attached that way. + A background sub-agent inherits from the lead run like a synchronous one (hooks, tracing, `onEvent`, usage roll-up), and `maxSubagentDepth` applies the same way. Aborting the lead's signal cancels its background sub-agents, queued or running. -Limits (follow-up LOU-Y4.2, which needs a run-end hook in the executor): +### When the lead run ends + +Background tasks belong to one run (one `execute()` / `send()` / `stream()`). +When that run ends, however it ends, the tasks still queued or running are +**cancelled** by default (their runs are aborted). With +`awaitBackgroundOnFinish: true` the run instead waits for them to finish +before it resolves (an aborted or failed lead run still cancels them). Either +way the run resolves only once their runs have stopped, so no sub-agent event +reaches `onEvent` after it; with `awaitBackgroundOnFinish` their events arrive +after the lead's own `finish` event. + +The result lists every background task of the run with its final status: + +```ts +import { createAgent } from '@loushy/build-ai-agent'; + +const researcher = createAgent({ provider, instructions: 'You research.', description: 'Finds and summarizes sources' }); +const lead = createAgent({ provider, instructions: 'You coordinate research.', subagents: { researcher } }); + +const result = await lead.send('Research three topics in the background.'); +for (const task of result.backgroundTasks ?? []) { + console.log(task.taskId, task.agent, task.status); // e.g. task_1 researcher cancelled +} +``` + +`result.backgroundTasks` holds `{ taskId, agent, status, elapsedMs, error?, +approvalId?, toolName? }` per task (the `agent_status` shape) and is absent +when the run started none. Use `agent_await` in the lead (the system prompt +tells it to) to get their answers into the conversation. + +The executor hook behind this is public: `ExecuteOptions.onRunEnd` is called +exactly once per run, with `{ result }` (whatever the `finishReason`) or +`{ error }`; the run settles after it returns. Background tasks are reported +on `result` before your `onRunEnd` sees it. + +Limits: -- Background tasks belong to one `execute()` call. When the lead's run - finishes, sub-agents still running are **not** cancelled or awaited yet: - they run to completion on their own, and their events still reach - `onEvent`. Have the lead collect them with `agent_await` before its final - answer (the system prompt tells it to), or abort the signal you passed to - the run to stop them. +- A lead run that pauses for approval (`finishReason: 'awaiting-approval'`) + ends there: its background tasks are cancelled (or awaited) like at any + other end, and the resumed run starts with none. The resumed run is a + separate run with no link to the paused one's tasks (it may even run in + another process), so they cannot be kept alive across the pause. - A background sub-agent that needs approval stops with `status: 'awaiting-approval'` (with the child's `approvalId` and `toolName`); its call does not run, and it cannot be resumed through the approval store. Run that task synchronously (`background: false`) when it may need approval. -- A lead that pauses for approval and is resumed starts with no background - tasks: those started before the pause cannot be observed afterwards. ## Dynamic catalogs diff --git a/src/createAgent.ts b/src/createAgent.ts index a66a9b1df..cc1adaf7c 100644 --- a/src/createAgent.ts +++ b/src/createAgent.ts @@ -31,7 +31,8 @@ import { AgentSession, type SessionOptions } from './session/AgentSession'; import { loadProjectInstructions } from './projectInstructions'; import { basename } from 'node:path'; import type { Subagents } from './subagents/types'; -import { assertMaxSubagentDepth, assertNoTaskTool, assertSubagents, registerSubagent } from './subagents/withSubagents'; +import type { SubagentOptions } from './subagents/backgroundTasks'; +import { assertMaxSubagentDepth, assertNoTaskTool, assertSubagents, registerSubagent, subagentsWithOptions } from './subagents/withSubagents'; import type { SubagentSpec } from './execution/delegation'; import type { ApprovalStore } from './execution/ApprovalGate'; import { InMemoryApprovalStore } from './execution/InMemoryApprovalStore'; @@ -90,6 +91,12 @@ export interface CreateAgentBase { * ``` */ subagents?: Subagents; + /** + * Background sub-agent options (LOU-Y4.2): `maxConcurrent` (default 3) and + * `awaitBackgroundOnFinish` (default `false`: tasks still running when a + * run ends are cancelled). Override those set with `withSubagentOptions()`. + */ + subagentOptions?: SubagentOptions; /** * How deep sub-agents may nest. Defaults to 1: this agent's sub-agents * cannot call sub-agents of their own (they are not offered the `task` @@ -349,7 +356,7 @@ export function createAgent( const runOptions = { skills: config.skills, - subagents: config.subagents, + subagents: subagentsWithOptions(config.subagents, config.subagentOptions), maxSubagentDepth: config.maxSubagentDepth, maxSteps: config.maxSteps, toolConcurrency: config.toolConcurrency, diff --git a/src/execution/AgentExecutor.ts b/src/execution/AgentExecutor.ts index c496756a4..d97434760 100644 --- a/src/execution/AgentExecutor.ts +++ b/src/execution/AgentExecutor.ts @@ -6,6 +6,7 @@ import type { Skill } from '../skills/defineSkill'; import { withSkills } from '../skills/withSkills'; import type { Subagents } from '../subagents/types'; +import type { BackgroundTaskView } from '../subagents/backgroundTasks'; import { assertMaxSubagentDepth, withSubagents } from '../subagents/withSubagents'; import type { z } from 'zod'; import { newId } from '../utils/id'; @@ -472,6 +473,13 @@ export interface ExecuteOptions { * ``` */ output?: z.ZodTypeAny; + /** + * LOU-Y4.2: called exactly once when this run ends, however it ends: with + * `{ result }` when it resolves (any `finishReason`, including `'aborted'`, + * `'awaiting-approval'` and `'max-steps'`) or `{ error }` when it rejects. + * The run settles after it returns; an error it throws rejects the run. + */ + onRunEnd?: (end: { result?: ExecutionResult; error?: unknown }) => void | Promise; } /** @@ -496,6 +504,8 @@ export interface ExecutionResult { object?: TObject; /** LOU-V4: why the final reply did not match `output` (`finishReason: 'output-invalid'`). */ outputError?: OutputError; + /** LOU-Y4.2: the run's background sub-agents and their final statuses; absent when it started none. */ + backgroundTasks?: BackgroundTaskView[]; } /** @@ -532,12 +542,29 @@ export class AgentExecutor { exporter, init.name, init.attributes, - async (agentSpan) => this.runAgentLoop(await this.withExtensions(options), agentSpan.id), + async (agentSpan) => this.runWithEnd(options, agentSpan.id), options.parentSpanId, init.kind ); } + /** runAgentLoop() with `skills`/`subagents` applied, then `onRunEnd` once, however the run ends (LOU-Y4.2). */ + private static async runWithEnd(options: ExecuteOptions, agentSpanId: string): Promise { + let run = options; + let end: { result?: ExecutionResult; error?: unknown } = {}; + try { + run = await this.withExtensions(options); + const result = await this.runAgentLoop(run, agentSpanId); + end = { result }; + return result; + } catch (error) { + end = { error }; + throw error; + } finally { + await run.onRunEnd?.(end); + } + } + /** Applies `skills` and `subagents`: their prompt blocks and their tools. */ private static async withExtensions(options: ExecuteOptions): Promise { const skilled = withSkills(options.agent, options.toolRegistry, options.skills); @@ -545,7 +572,8 @@ export class AgentExecutor { skilled.agent, skilled.toolRegistry, options.subagents, - options.maxSubagentDepth + options.maxSubagentDepth, + options.onRunEnd ); // LOU-V4: the output instruction goes last in the system prompt. const { agent } = extended; diff --git a/src/execution/runEnd.test.ts b/src/execution/runEnd.test.ts new file mode 100644 index 000000000..5df1919ae --- /dev/null +++ b/src/execution/runEnd.test.ts @@ -0,0 +1,76 @@ +/** + * LOU-Y4.2: `ExecuteOptions.onRunEnd` is called exactly once per run, + * however the run ends. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { z } from 'zod'; +import { AgentExecutor, type ExecuteOptions } from './AgentExecutor'; +import { InMemoryApprovalStore } from './InMemoryApprovalStore'; +import { createAgent } from '../createAgent'; +import { defineTool, type DefinedTool } from '../tools/defineTool'; +import { ToolRegistry } from '../tools'; +import { mockModel, type MockTurn } from '../testing'; +import type { AgentConfig } from '../types'; + +const ping = defineTool({ name: 'ping', description: 'Ping', input: z.object({}), execute: () => 'pong' }); +const gated = defineTool({ name: 'gated', description: 'Gated', input: z.object({}), needsApproval: true, execute: () => 'ok' }); + +function options(script: MockTurn[], tools: DefinedTool[] = [ping, gated]) { + const toolRegistry = new ToolRegistry(); + toolRegistry.registerMany(tools); + const agent: AgentConfig = { id: 'a', name: 'Agent', prompt: 'p', tools: Object.fromEntries(tools.map((t) => [t.name, { tool: t.name }])) }; + const onRunEnd = vi.fn>(); + return { agent, provider: mockModel(script), toolRegistry, input: 'go', onRunEnd } satisfies ExecuteOptions; +} + +describe('ExecuteOptions.onRunEnd (LOU-Y4.2)', () => { + it('is called once with the result when the run finishes', async () => { + const run = options(['hi']); + const result = await AgentExecutor.execute(run); + expect(run.onRunEnd).toHaveBeenCalledTimes(1); + expect(run.onRunEnd).toHaveBeenCalledWith({ result }); + }); + + it.each([ + ['aborted', (o: ExecuteOptions): ExecuteOptions => ({ ...o, signal: AbortSignal.abort() })], + ['max-steps', (o: ExecuteOptions): ExecuteOptions => ({ ...o, maxSteps: 1 })], + ['awaiting-approval', (o: ExecuteOptions): ExecuteOptions => ({ ...o, approvalStore: new InMemoryApprovalStore() })], + ])('is called once when the run ends with %s', async (finishReason, configure) => { + const run = options([{ toolCalls: [{ name: finishReason === 'awaiting-approval' ? 'gated' : 'ping' }] }, 'never']); + const result = await AgentExecutor.execute(configure(run)); + expect(result.finishReason).toBe(finishReason); + expect(run.onRunEnd).toHaveBeenCalledTimes(1); + expect(run.onRunEnd).toHaveBeenCalledWith({ result }); + }); + + it('is called once with the error when the run rejects', async () => { + const run = options([{ error: new Error('provider down') }]); + await expect(AgentExecutor.execute(run)).rejects.toThrow('provider down'); + expect(run.onRunEnd).toHaveBeenCalledTimes(1); + expect(run.onRunEnd.mock.calls[0][0].error).toBeInstanceOf(Error); + }); + + it('is called with the error when the sub-agent catalog fails before the loop', async () => { + const run = options(['hi']); + const subagents = { list: () => Promise.reject(new Error('catalog down')), resolve: () => undefined }; + await expect(AgentExecutor.execute({ ...run, subagents })).rejects.toThrow('catalog down'); + expect(run.onRunEnd).toHaveBeenCalledTimes(1); + }); + + it('is called once per stream() run, also with sub-agents (no background task: no backgroundTasks)', async () => { + const run = options(['hi']); + const researcher = createAgent({ provider: mockModel([]), description: 'Researches' }); + const stream = AgentExecutor.stream({ ...run, subagents: { researcher } }); + for await (const event of stream) void event; + const result = await stream.result; + expect(run.onRunEnd).toHaveBeenCalledTimes(1); + expect(run.onRunEnd).toHaveBeenCalledWith({ result }); + expect(result.backgroundTasks).toBeUndefined(); + }); + + it('rejects the run when it throws', async () => { + const run = { ...options(['hi']), onRunEnd: () => Promise.reject(new Error('cleanup failed')) }; + await expect(AgentExecutor.execute(run)).rejects.toThrow('cleanup failed'); + }); +}); diff --git a/src/subagents/backgroundSubagents.test.ts b/src/subagents/backgroundSubagents.test.ts index e0466902d..0b7a74a0c 100644 --- a/src/subagents/backgroundSubagents.test.ts +++ b/src/subagents/backgroundSubagents.test.ts @@ -4,7 +4,9 @@ import { createAgent } from '../createAgent'; import { defineTool } from '../tools/defineTool'; import { mockModel, type MockTurn } from '../testing'; import type { Message } from '../providers'; -import { withSubagentOptions } from './backgroundTasks'; +import { subagentOptionsOf, withSubagentOptions } from './backgroundTasks'; +import { AgentExecutor, type ExecutionEvent } from '../execution/AgentExecutor'; +import type { AgentConfig } from '../types'; const bgTask = (agent: string, prompt: string) => ({ name: 'task', @@ -196,3 +198,91 @@ describe('background sub-agents (LOU-Y4)', () => { expect(() => withSubagentOptions({}, { maxConcurrent: 0 })).toThrow(/maxConcurrent/); }); }); + +describe('background sub-agents at the end of the lead run (LOU-Y4.2)', () => { + const statuses = (tasks: readonly { status: string }[] | undefined) => tasks?.map((t) => t.status); + + it('cancels queued and running background sub-agents when the lead finishes, before the run resolves', async () => { + const { tool, aborted, entered } = gate(); + const events: ExecutionEvent[] = []; + const agent: AgentConfig = { id: 'lead', name: 'Lead', prompt: 'p' }; + const finishOnceRunning = async () => (await entered, 'done'); + const result = await AgentExecutor.execute({ + agent, + input: 'go', + provider: mockModel([{ toolCalls: [bgTask('researcher', 'a'), bgTask('researcher', 'b')] }, finishOnceRunning]), + subagents: withSubagentOptions({ researcher: waitingChild(tool) }, { maxConcurrent: 1 }), + onEvent: (event) => events.push(event), + }); + + expect(result.text).toBe('done'); + expect(result.backgroundTasks).toEqual([ + { taskId: 'task_1', agent: 'researcher', status: 'cancelled', elapsedMs: expect.any(Number) }, + { taskId: 'task_2', agent: 'researcher', status: 'cancelled', elapsedMs: expect.any(Number) }, + ]); + await aborted; + // The running child has wound down by then: its last event came before the run resolved. + expect(events.some((e) => e.type === 'finish' && e.subagent?.name === 'researcher')).toBe(true); + const seen = events.length; + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(events).toHaveLength(seen); + }); + + it('awaitBackgroundOnFinish waits for them and reports their final statuses', async () => { + const { tool, release } = gate(); + const lead = createAgent({ + provider: mockModel([{ toolCalls: [bgTask('researcher', 'a')] }, () => (setTimeout(release, 5), 'done')]), + subagents: { researcher: waitingChild(tool) }, + subagentOptions: { awaitBackgroundOnFinish: true }, + }); + + const result = await lead.send('go'); + + expect(result.text).toBe('done'); + expect(result.backgroundTasks).toEqual([{ taskId: 'task_1', agent: 'researcher', status: 'done', elapsedMs: expect.any(Number) }]); + }); + + it('createAgent({ subagentOptions }) is forwarded over withSubagentOptions(), without changing the subagents value', async () => { + const { tool, release } = gate(); + const subagents = withSubagentOptions({ researcher: waitingChild(tool) }, { maxConcurrent: 3, awaitBackgroundOnFinish: true }); + const lead = createAgent({ + provider: mockModel([{ toolCalls: [bgTask('researcher', 'a'), bgTask('researcher', 'b')] }, () => (release(), 'done')]), + subagents, + subagentOptions: { maxConcurrent: 1 }, + }); + + const result = await lead.send('go'); + + expect(results(result.messages, 'task').map((r) => r.status)).toEqual(['running', 'queued']); + expect(statuses(result.backgroundTasks)).toEqual(['done', 'done']); + expect(subagentOptionsOf(subagents)).toEqual({ maxConcurrent: 3, awaitBackgroundOnFinish: true }); + }); + + it('cancels them when the lead run fails, even with awaitBackgroundOnFinish', async () => { + const { tool, aborted, entered } = gate(); + const lead = createAgent({ + provider: mockModel([{ toolCalls: [bgTask('researcher', 'a')] }, async () => (await entered, { error: new Error('model down') })]), + subagents: { researcher: waitingChild(tool) }, + subagentOptions: { awaitBackgroundOnFinish: true }, + }); + + await expect(lead.send('go')).rejects.toThrow('model down'); + await aborted; + }); + + it('cancels them when the lead pauses for approval', async () => { + const { tool, aborted, entered } = gate(); + const deploy = defineTool({ name: 'deploy', description: 'Deploys', input: z.object({}), needsApproval: true, execute: () => 'deployed' }); + const lead = createAgent({ + provider: mockModel([{ toolCalls: [bgTask('researcher', 'a')] }, async () => (await entered, { toolCalls: [{ name: 'deploy' }] })]), + tools: [deploy], + subagents: { researcher: waitingChild(tool) }, + }); + + const result = await lead.send('go'); + + expect(result.finishReason).toBe('awaiting-approval'); + expect(statuses(result.backgroundTasks)).toEqual(['cancelled']); + await aborted; + }); +}); diff --git a/src/subagents/backgroundTasks.ts b/src/subagents/backgroundTasks.ts index 1938d1ac1..0b57fa57a 100644 --- a/src/subagents/backgroundTasks.ts +++ b/src/subagents/backgroundTasks.ts @@ -14,6 +14,12 @@ export type BackgroundTaskStatus = 'queued' | 'running' | 'done' | 'failed' | 'c export interface SubagentOptions { /** How many background sub-agents of one lead run run at once (default 3). Further `background: true` calls queue. */ maxConcurrent?: number; + /** + * What happens to background sub-agents still queued or running when the + * lead run ends: `false` (default) cancels them, `true` waits for them + * before the run resolves. Either way `result.backgroundTasks` reports them. + */ + awaitBackgroundOnFinish?: boolean; } /** What `agent_status` / `agent_await` report for a task. */ @@ -40,6 +46,8 @@ interface BackgroundTask extends BackgroundTaskView { launch: () => void; settled: Promise; settle: () => void; + /** Resolves once the child run itself has returned (after a cancel, once it has wound down). */ + stopped: Promise; } const DEFAULT_MAX_CONCURRENT = 3; @@ -94,10 +102,11 @@ export class BackgroundTasks { controller, settled: new Promise((resolve) => (settle = resolve)), settle, + stopped: Promise.resolve(), launch: () => { this.running++; task.status = 'running'; - run(controller.signal).then( + task.stopped = run(controller.signal).then( (result) => this.end(task, { status: 'done', result }), (error: unknown) => this.end(task, failure(error)) ); @@ -142,6 +151,20 @@ export class BackgroundTasks { return this.view(task); } + /** + * At the end of the lead run (LOU-Y4.2): cancels the tasks still queued or + * running - or, with `awaitAll`, waits for them to end - then waits until + * their runs have stopped, so none of their events comes after this. Returns + * every task with its final status (none when no task was started). + */ + async finish(awaitAll: boolean): Promise { + const tasks = [...this.tasks.values()]; + if (awaitAll) await Promise.all(tasks.map((task) => task.settled)); + else tasks.forEach((task) => this.cancel(task.taskId)); + await Promise.all(tasks.map((task) => task.stopped)); + return this.status(); + } + private watch(signal: AbortSignal | undefined): void { if (!signal || signal === this.watchedSignal) return; this.watchedSignal = signal; diff --git a/src/subagents/withSubagents.ts b/src/subagents/withSubagents.ts index 8ed65da62..c8a257da0 100644 --- a/src/subagents/withSubagents.ts +++ b/src/subagents/withSubagents.ts @@ -14,9 +14,9 @@ import { withPromptTool } from '../skills/withSkills'; import { runSubagent, type SubagentSpec } from '../execution/delegation'; import { bindToolCallScope, extendAgent, subagentBudget, toolCallScopeOf } from '../execution/subagentRuntime'; import { isPropagatingToolError } from '../execution/propagatingToolError'; -import type { ExecutionResult } from '../execution/AgentExecutor'; +import type { ExecuteOptions, ExecutionResult } from '../execution/AgentExecutor'; import type { SubagentCatalog, SubagentSummary, Subagents } from './types'; -import { BackgroundTasks, subagentOptionsOf } from './backgroundTasks'; +import { BackgroundTasks, subagentOptionsOf, withSubagentOptions, type SubagentOptions } from './backgroundTasks'; /** Name of the tool the lead model delegates with. */ const TASK_TOOL = 'task'; @@ -100,6 +100,19 @@ function assertSummaries(summaries: readonly SubagentSummary[]): void { } } +/** + * `createAgent({ subagentOptions })`: a copy of `subagents` with `options` + * over the ones attached with `withSubagentOptions()` (the caller's value is + * left as is, so it can be shared between agents). + */ +export function subagentsWithOptions(subagents: Subagents | undefined, options: SubagentOptions | undefined): Subagents | undefined { + if (!subagents || !options) return subagents; + const copy: Subagents = isCatalog(subagents) + ? { list: () => subagents.list(), resolve: (name) => subagents.resolve(name) } + : { ...subagents }; + return withSubagentOptions(copy, { ...subagentOptionsOf(subagents), ...options }); +} + async function listSubagents(subagents: Subagents): Promise { if (isCatalog(subagents)) { const summaries = [...(await subagents.list())]; @@ -265,32 +278,54 @@ export function assertNoTaskTool(agent: AgentConfig, toolRegistry: ToolRegistry } } +type OnRunEnd = ExecuteOptions['onRunEnd']; + +/** + * The run's `onRunEnd` (LOU-Y4.2): first cancels (or, with + * `awaitBackgroundOnFinish`, awaits) the background tasks still active and + * reports them on `result.backgroundTasks`, then calls `next`. + */ +function endBackgroundTasks(background: BackgroundTasks, awaitAll: boolean, next: OnRunEnd): NonNullable { + return async (end) => { + const tasks = await background.finish(awaitAll && end.result !== undefined); + if (end.result && tasks.length > 0) end.result.backgroundTasks = tasks; + await next?.(end); + }; +} + /** * Applies `subagents` to an agent run: adds the `task` tool and the * "Available sub-agents" prompt block - unless this run is already at the - * sub-agent depth limit (see `maxSubagentDepth`), or the catalog is empty. + * sub-agent depth limit (see `maxSubagentDepth`), or the catalog is empty - + * and wraps `onRunEnd` so the run's background tasks end with it. * Inputs are not mutated. */ export async function withSubagents( agent: AgentConfig, toolRegistry: ToolRegistry | undefined, subagents: Subagents | undefined, - maxSubagentDepth: number | undefined -): Promise<{ agent: AgentConfig; toolRegistry: ToolRegistry | undefined }> { + maxSubagentDepth: number | undefined, + onRunEnd?: OnRunEnd +): Promise<{ agent: AgentConfig; toolRegistry: ToolRegistry | undefined; onRunEnd: OnRunEnd }> { if (!subagents || subagentBudget(maxSubagentDepth) <= 0) { - return { agent, toolRegistry }; + return { agent, toolRegistry, onRunEnd }; } assertNoTaskTool(agent, toolRegistry); const summaries = await listSubagents(subagents); if (summaries.length === 0) { - return { agent, toolRegistry }; + return { agent, toolRegistry, onRunEnd }; } - const background = new BackgroundTasks(subagentOptionsOf(subagents).maxConcurrent); + const options = subagentOptionsOf(subagents); + const background = new BackgroundTasks(options.maxConcurrent); const extended = withPromptTool(agent, toolRegistry, createTaskTool(subagents, summaries, background), subagentsPromptBlock(summaries)); const tools = { ...extended.agent.tools }; for (const tool of createBackgroundTools(background)) { extended.toolRegistry.register(tool); tools[tool.name] = { tool: tool.name }; } - return { agent: extendAgent(extended.agent, { tools }), toolRegistry: extended.toolRegistry }; + return { + agent: extendAgent(extended.agent, { tools }), + toolRegistry: extended.toolRegistry, + onRunEnd: endBackgroundTasks(background, options.awaitBackgroundOnFinish === true, onRunEnd), + }; }