From ea1fa96f2eaf395231983c75b1cd8f8715ec27d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 14:37:47 +0000 Subject: [PATCH] [EPIC-Y][LOU-Y4] Background sub-agents: task background:true with agent_status, agent_await and agent_cancel 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, agent_await and agent_cancel. withSubagentOptions(subagents, { maxConcurrent }) bounds running background children per lead run (default 3); the rest queue. Background children inherit the lead run's runtime and depth budget, and aborting the lead cancels them. A child that pauses for approval reports awaiting-approval with its approvalId. Cancelling or awaiting background children when the lead run finishes needs a run-end hook in AgentExecutor (out of scope here): left to LOU-Y4.2 and documented. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016bY29ibQgv3MDdjAbUT4YR --- CHANGELOG.md | 1 + docs/sub-agents.md | 56 +++++- llms-full.txt | 56 +++++- src/subagents/backgroundSubagents.test.ts | 198 ++++++++++++++++++++++ src/subagents/backgroundTasks.ts | 193 +++++++++++++++++++++ src/subagents/index.ts | 2 + src/subagents/subagents.test.ts | 12 +- src/subagents/withSubagents.ts | 101 +++++++++-- 8 files changed, 597 insertions(+), 22 deletions(-) create mode 100644 src/subagents/backgroundSubagents.test.ts create mode 100644 src/subagents/backgroundTasks.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f5719e15f..60d1f589d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - 2026-09-28 ### Added +- 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. - 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. diff --git a/docs/sub-agents.md b/docs/sub-agents.md index 8b7620131..79757954d 100644 --- a/docs/sub-agents.md +++ b/docs/sub-agents.md @@ -55,8 +55,10 @@ With `subagents`, the lead agent gets: sub-agent (`- name: description`), plus a sentence saying that a sub-agent sees only the prompt it is given. 2. ONE tool, `task`, with the input - `{ agent: , prompt: string, description: string }` + `{ agent: , prompt: string, description: string, background?: boolean }` (`description` is a 3-5 word label used in events and hooks). +3. Three tools for background tasks: `agent_status`, `agent_await` and + `agent_cancel` (see [Background sub-agents](#background-sub-agents)). When the lead calls `task`, the named sub-agent runs on `prompt` alone. The tool result is the sub-agent's final text plus a small footer the lead can @@ -76,7 +78,7 @@ the valid names. Sub-agent token usage is added to the lead's `result.usage`. Errors at setup time are thrown with a fix: a sub-agent without a `description`, a value that is not a `createAgent()` agent, or a tool of your -own already named `task`. +own already named `task` (or `agent_status`, `agent_await`, `agent_cancel`). ## Parallel tasks @@ -85,6 +87,56 @@ the lead calls `task` several times in one turn, those sub-agents run in parallel. Results reach the lead's transcript in the order the model made the calls. Cap it with `toolConcurrency` on the lead (`1` runs them one at a time). +## Background sub-agents + +With `background: true`, `task` starts the sub-agent and returns at once with +`{ taskId, status: 'running', agent }`, so the lead can keep working (or start +more tasks) while it runs. The lead then uses: + +| Tool | Input | Result | +| --- | --- | --- | +| `agent_status` | `{ taskId? }` | `{ tasks: [{ taskId, agent, status, elapsedMs, approvalId?, toolName?, error? }] }` for one task or all of them. `status` is `queued`, `running`, `done`, `failed`, `cancelled` or `awaiting-approval`. | +| `agent_await` | `{ taskId }` or `{ taskIds }`, optional `timeoutMs` | Waits until the task(s) end. A `done` task carries `result`: the same text (with footer) a synchronous `task` returns. A `failed` one carries `error`. A task still running after `timeoutMs` reports `status: 'timeout'`. With `taskIds` the result is `{ tasks: [...] }`. | +| `agent_cancel` | `{ taskId }` | Cancels a queued or running task (its run is aborted) and returns its status. | + +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: + +```ts +import { createAgent, withSubagentOptions } 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 }), +}); +``` + +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): + +- 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 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 Instead of a fixed record, pass a catalog with `list()` and `resolve(name)`. diff --git a/llms-full.txt b/llms-full.txt index 368704a29..36949bd0f 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -4460,8 +4460,10 @@ With `subagents`, the lead agent gets: sub-agent (`- name: description`), plus a sentence saying that a sub-agent sees only the prompt it is given. 2. ONE tool, `task`, with the input - `{ agent: , prompt: string, description: string }` + `{ agent: , prompt: string, description: string, background?: boolean }` (`description` is a 3-5 word label used in events and hooks). +3. Three tools for background tasks: `agent_status`, `agent_await` and + `agent_cancel` (see [Background sub-agents](#background-sub-agents)). When the lead calls `task`, the named sub-agent runs on `prompt` alone. The tool result is the sub-agent's final text plus a small footer the lead can @@ -4481,7 +4483,7 @@ the valid names. Sub-agent token usage is added to the lead's `result.usage`. Errors at setup time are thrown with a fix: a sub-agent without a `description`, a value that is not a `createAgent()` agent, or a tool of your -own already named `task`. +own already named `task` (or `agent_status`, `agent_await`, `agent_cancel`). ## Parallel tasks @@ -4490,6 +4492,56 @@ the lead calls `task` several times in one turn, those sub-agents run in parallel. Results reach the lead's transcript in the order the model made the calls. Cap it with `toolConcurrency` on the lead (`1` runs them one at a time). +## Background sub-agents + +With `background: true`, `task` starts the sub-agent and returns at once with +`{ taskId, status: 'running', agent }`, so the lead can keep working (or start +more tasks) while it runs. The lead then uses: + +| Tool | Input | Result | +| --- | --- | --- | +| `agent_status` | `{ taskId? }` | `{ tasks: [{ taskId, agent, status, elapsedMs, approvalId?, toolName?, error? }] }` for one task or all of them. `status` is `queued`, `running`, `done`, `failed`, `cancelled` or `awaiting-approval`. | +| `agent_await` | `{ taskId }` or `{ taskIds }`, optional `timeoutMs` | Waits until the task(s) end. A `done` task carries `result`: the same text (with footer) a synchronous `task` returns. A `failed` one carries `error`. A task still running after `timeoutMs` reports `status: 'timeout'`. With `taskIds` the result is `{ tasks: [...] }`. | +| `agent_cancel` | `{ taskId }` | Cancels a queued or running task (its run is aborted) and returns its status. | + +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: + +```ts +import { createAgent, withSubagentOptions } 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 }), +}); +``` + +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): + +- 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 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 Instead of a fixed record, pass a catalog with `list()` and `resolve(name)`. diff --git a/src/subagents/backgroundSubagents.test.ts b/src/subagents/backgroundSubagents.test.ts new file mode 100644 index 000000000..e0466902d --- /dev/null +++ b/src/subagents/backgroundSubagents.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { createAgent } from '../createAgent'; +import { defineTool } from '../tools/defineTool'; +import { mockModel, type MockTurn } from '../testing'; +import type { Message } from '../providers'; +import { withSubagentOptions } from './backgroundTasks'; + +const bgTask = (agent: string, prompt: string) => ({ + name: 'task', + args: { agent, prompt, description: `${agent} task`, background: true }, +}); + +/** The parsed results of the lead's tool calls named `name`, in order. */ +function results(messages: readonly Message[], name: string): Record[] { + return messages + .filter((m) => m.role === 'tool' && m.toolName === name) + .map((m) => JSON.parse(m.content as string) as Record); +} + +/** A child tool that blocks until `release()` or until its run is aborted. */ +function gate() { + let release!: () => void; + const opened = new Promise((resolve) => (release = resolve)); + let onAbort!: () => void; + const aborted = new Promise((resolve) => (onAbort = resolve)); + let onEnter!: () => void; + const entered = new Promise((resolve) => (onEnter = resolve)); + const tool = defineTool({ + name: 'wait', + description: 'Waits', + input: z.object({}), + execute: (_args, ctx) => + new Promise((resolve, reject) => { + onEnter(); + void opened.then(() => resolve('waited')); + ctx.abortSignal?.addEventListener('abort', () => { + onAbort(); + reject(new Error('aborted')); + }); + }), + }); + return { tool, release, aborted, entered }; +} + +/** A sub-agent that calls `wait` once, then answers with `answer` (`#n` for its n-th run). */ +function waitingChild(tool: ReturnType['tool'], answer = 'answer') { + let runs = 0; + const turn: MockTurn = (req) => + req.messages.at(-1)?.role === 'tool' ? `${answer} #${runs}` : (runs++, { toolCalls: [{ name: 'wait' }] }); + return createAgent({ provider: mockModel([turn], { onExhausted: 'repeat-last' }), tools: [tool], description: 'Researches' }); +} + +describe('background sub-agents (LOU-Y4)', () => { + it('returns a taskId at once; agent_status and agent_await report the answer', async () => { + const { tool, release } = gate(); + const researcher = waitingChild(tool, 'Paris'); + const leadModel = mockModel([ + { toolCalls: [bgTask('researcher', 'capital of France?')] }, + { toolCalls: [{ name: 'agent_status', args: {} }] }, + () => (release(), { toolCalls: [{ name: 'agent_await', args: { taskId: 'task_1' } }] }), + 'done', + ]); + const lead = createAgent({ provider: leadModel, subagents: { researcher } }); + + const result = await lead.send('go'); + + expect(results(result.messages, 'task')).toEqual([{ taskId: 'task_1', status: 'running', agent: 'researcher' }]); + const [status] = results(result.messages, 'agent_status'); + expect(status.tasks).toEqual([{ taskId: 'task_1', agent: 'researcher', status: 'running', elapsedMs: expect.any(Number) }]); + expect(results(result.messages, 'agent_await')[0]).toMatchObject({ + taskId: 'task_1', + status: 'done', + result: "Paris #1\n\n[sub-agent 'researcher': 2 step(s), finish reason 'stop']", + }); + expect(leadModel.calls[0].tools?.map((t) => t.function.name)).toEqual(['task', 'agent_status', 'agent_await', 'agent_cancel']); + expect(result.text).toBe('done'); + }); + + it('queues tasks beyond maxConcurrent and starts them as slots free', async () => { + const { tool, release } = gate(); + const researcher = waitingChild(tool); + const leadModel = mockModel([ + { toolCalls: [bgTask('researcher', 'a'), bgTask('researcher', 'b')] }, + { toolCalls: [{ name: 'agent_status', args: {} }] }, + () => (release(), { toolCalls: [{ name: 'agent_await', args: { taskIds: ['task_1', 'task_2'] } }] }), + 'done', + ]); + const lead = createAgent({ provider: leadModel, subagents: withSubagentOptions({ researcher }, { maxConcurrent: 1 }) }); + + const result = await lead.send('go'); + + expect(results(result.messages, 'task').map((r) => r.status)).toEqual(['running', 'queued']); + const [status] = results(result.messages, 'agent_status'); + expect((status.tasks as { status: string }[]).map((t) => t.status)).toEqual(['running', 'queued']); + const [awaited] = results(result.messages, 'agent_await'); + expect((awaited.tasks as { status: string; result: string }[]).map((t) => [t.status, t.result.split('\n')[0]])).toEqual([ + ['done', 'answer #1'], + ['done', 'answer #2'], + ]); + }); + + it('agent_await times out on a running task, and agent_cancel stops it', async () => { + const { tool, aborted } = gate(); + const lead = createAgent({ + provider: mockModel([ + { toolCalls: [bgTask('researcher', 'a')] }, + { toolCalls: [{ name: 'agent_await', args: { taskId: 'task_1', timeoutMs: 5 } }] }, + { toolCalls: [{ name: 'agent_cancel', args: { taskId: 'task_1' } }] }, + { toolCalls: [{ name: 'agent_await', args: { taskId: 'task_1' } }] }, + 'done', + ]), + subagents: { researcher: waitingChild(tool) }, + }); + + const result = await lead.send('go'); + + expect(results(result.messages, 'agent_await').map((r) => r.status)).toEqual(['timeout', 'cancelled']); + expect(results(result.messages, 'agent_cancel')[0]).toMatchObject({ taskId: 'task_1', status: 'cancelled' }); + await aborted; + }); + + it('aborting the lead cancels its background sub-agents', async () => { + const { tool, aborted, entered } = gate(); + const controller = new AbortController(); + const abortOnceRunning = async () => { + await entered; + controller.abort(); + return 'stopping'; + }; + const lead = createAgent({ + provider: mockModel([{ toolCalls: [bgTask('researcher', 'a')] }, abortOnceRunning]), + subagents: { researcher: waitingChild(tool) }, + }); + + await lead.send('go', { signal: controller.signal }); + + // The child's in-flight tool call sees the abort (the test times out otherwise). + await aborted; + }); + + it('reports a sub-agent paused for approval as awaiting-approval, with its approvalId', async () => { + let ran = false; + const deploy = defineTool({ name: 'deploy', description: 'Deploys', input: z.object({}), needsApproval: true, execute: () => (ran = true) }); + const researcher = createAgent({ provider: mockModel([{ toolCalls: [{ name: 'deploy' }] }]), tools: [deploy], description: 'Deploys' }); + const lead = createAgent({ + provider: mockModel([ + { toolCalls: [bgTask('researcher', 'ship it')] }, + { toolCalls: [{ name: 'agent_await', args: { taskId: 'task_1' } }] }, + 'done', + ]), + subagents: { researcher }, + }); + + const result = await lead.send('go'); + + expect(results(result.messages, 'agent_await')[0]).toMatchObject({ + status: 'awaiting-approval', + toolName: 'deploy', + approvalId: expect.any(String), + }); + expect(ran).toBe(false); + expect(result.text).toBe('done'); + }); + + it('still applies maxSubagentDepth to background sub-agents', async () => { + const researcherModel = mockModel(['researched']); + const researcher = createAgent({ + provider: researcherModel, + description: 'Researches', + subagents: { deeper: createAgent({ provider: mockModel(['x']), description: 'Deeper' }) }, + }); + const lead = createAgent({ + provider: mockModel([ + { toolCalls: [bgTask('researcher', 'a')] }, + { toolCalls: [{ name: 'agent_await', args: { taskId: 'task_1' } }] }, + 'done', + ]), + subagents: { researcher }, + }); + + const result = await lead.send('go'); + + expect(researcherModel.calls[0].tools).toBeUndefined(); + expect(results(result.messages, 'agent_await')[0]).toMatchObject({ status: 'done' }); + }); + + it('reports unknown task ids and bad options', async () => { + const lead = createAgent({ + provider: mockModel([{ toolCalls: [{ name: 'agent_cancel', args: { taskId: 'task_9' } }] }, 'ok']), + subagents: { researcher: createAgent({ provider: mockModel([]), description: 'Researches' }) }, + }); + const [message] = (await lead.send('go')).messages.filter((m) => m.role === 'tool'); + expect(message.isError).toBe(true); + expect(message.content).toContain("Unknown background task 'task_9'"); + expect(() => withSubagentOptions({}, { maxConcurrent: 0 })).toThrow(/maxConcurrent/); + }); +}); diff --git a/src/subagents/backgroundTasks.ts b/src/subagents/backgroundTasks.ts new file mode 100644 index 000000000..1938d1ac1 --- /dev/null +++ b/src/subagents/backgroundTasks.ts @@ -0,0 +1,193 @@ +/** + * Background sub-agents (LOU-Y4): `task` with `background: true` starts the + * child and returns at once; `agent_status`, `agent_await` and `agent_cancel` + * observe and steer it. One {@link BackgroundTasks} exists per lead run. + */ + +import { SubagentApprovalPause } from '../execution/subagentRuntime'; +import type { Subagents } from './types'; + +/** State of a background sub-agent task. */ +export type BackgroundTaskStatus = 'queued' | 'running' | 'done' | 'failed' | 'cancelled' | 'awaiting-approval'; + +/** Options for the `subagents` of a lead agent, attached with {@link withSubagentOptions}. */ +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 `agent_status` / `agent_await` report for a task. */ +export interface BackgroundTaskView { + taskId: string; + agent: string; + status: BackgroundTaskStatus | 'timeout'; + /** Milliseconds since the `task` call (until the task ended). */ + elapsedMs: number; + /** The final answer, as the synchronous `task` result (`done`, `agent_await` only). */ + result?: string; + /** Why it failed (`failed`). */ + error?: string; + /** The child's pending approval (`awaiting-approval`). */ + approvalId?: string; + toolName?: string; +} + +interface BackgroundTask extends BackgroundTaskView { + status: BackgroundTaskStatus; + createdAt: number; + endedAt?: number; + controller: AbortController; + launch: () => void; + settled: Promise; + settle: () => void; +} + +const DEFAULT_MAX_CONCURRENT = 3; +const optionsBySubagents = new WeakMap(); + +/** + * Attaches {@link SubagentOptions} to a `subagents` value and returns it, so + * it can be passed to `createAgent({ subagents })` or `AgentExecutor.execute()`. + * + * @example + * ```ts + * const lead = createAgent({ provider, subagents: withSubagentOptions({ researcher }, { maxConcurrent: 2 }) }); + * ``` + */ +export function withSubagentOptions(subagents: T, options: SubagentOptions): T { + const { maxConcurrent } = options; + if (maxConcurrent !== undefined && !(Number.isInteger(maxConcurrent) && maxConcurrent >= 1)) { + throw new Error(`withSubagentOptions: 'maxConcurrent' must be a whole number >= 1, got ${String(maxConcurrent)}.`); + } + optionsBySubagents.set(subagents, options); + return subagents; +} + +/** The options attached to `subagents` with {@link withSubagentOptions}. */ +export function subagentOptionsOf(subagents: Subagents): SubagentOptions { + return optionsBySubagents.get(subagents) ?? {}; +} + +/** The background tasks of one lead run. */ +export class BackgroundTasks { + private readonly tasks = new Map(); + private readonly queue: BackgroundTask[] = []; + private running = 0; + private watchedSignal: AbortSignal | undefined; + + constructor(private readonly maxConcurrent = DEFAULT_MAX_CONCURRENT) {} + + /** + * Queues `run` (the child run, resolving to the `task` result text) and + * starts it when a slot is free. Aborting `parentSignal` cancels every task. + */ + start(agent: string, run: (signal: AbortSignal) => Promise, parentSignal: AbortSignal | undefined): BackgroundTaskView { + this.watch(parentSignal); + const controller = new AbortController(); + let settle!: () => void; + const task: BackgroundTask = { + taskId: `task_${this.tasks.size + 1}`, + agent, + status: 'queued', + elapsedMs: 0, + createdAt: Date.now(), + controller, + settled: new Promise((resolve) => (settle = resolve)), + settle, + launch: () => { + this.running++; + task.status = 'running'; + run(controller.signal).then( + (result) => this.end(task, { status: 'done', result }), + (error: unknown) => this.end(task, failure(error)) + ); + }, + }; + this.tasks.set(task.taskId, task); + this.queue.push(task); + if (parentSignal?.aborted) this.cancel(task.taskId); + this.pump(); + return this.view(task); + } + + /** One task, or all of them. */ + status(taskId?: string): BackgroundTaskView[] { + const tasks = taskId === undefined ? [...this.tasks.values()] : [this.get(taskId)]; + return tasks.map((task) => ({ ...this.view(task), result: undefined })); + } + + /** Waits until each task has ended or paused, or `timeoutMs` passed (those report `timeout`). */ + async wait(taskIds: readonly string[], timeoutMs: number | undefined, signal: AbortSignal | undefined): Promise { + const tasks = taskIds.map((id) => this.get(id)); + let timer: ReturnType | undefined; + const waits: Promise[] = [Promise.all(tasks.map((task) => task.settled))]; + if (timeoutMs !== undefined) waits.push(new Promise((resolve) => (timer = setTimeout(resolve, timeoutMs)))); + if (signal) waits.push(new Promise((resolve) => signal.addEventListener('abort', resolve, { once: true }))); + await Promise.race(waits); + clearTimeout(timer); + return tasks.map((task) => (isActive(task) ? { ...this.view(task), status: 'timeout' } : this.view(task))); + } + + /** Cancels a queued or running task; a task that already ended is left as is. */ + cancel(taskId: string): BackgroundTaskView { + const task = this.get(taskId); + if (isActive(task)) { + const queued = this.queue.indexOf(task); + if (queued !== -1) this.queue.splice(queued, 1); + task.status = 'cancelled'; + task.endedAt = Date.now(); + task.controller.abort(new DOMException(`Background task ${taskId} was cancelled`, 'AbortError')); + task.settle(); + } + return this.view(task); + } + + private watch(signal: AbortSignal | undefined): void { + if (!signal || signal === this.watchedSignal) return; + this.watchedSignal = signal; + signal.addEventListener('abort', () => this.tasks.forEach((task) => this.cancel(task.taskId)), { once: true }); + } + + private pump(): void { + while (this.running < this.maxConcurrent && this.queue.length > 0) { + this.queue.shift()?.launch(); + } + } + + private end(task: BackgroundTask, outcome: Pick): void { + this.running--; + if (task.status === 'running') { + Object.assign(task, outcome, { endedAt: Date.now() }); + task.settle(); + } + this.pump(); + } + + private get(taskId: string): BackgroundTask { + const task = this.tasks.get(taskId); + if (!task) { + const known = [...this.tasks.keys()].join(', ') || 'none yet'; + throw new Error(`Unknown background task '${taskId}'. Known tasks: ${known}.`); + } + return task; + } + + private view(task: BackgroundTask): BackgroundTaskView { + const { taskId, agent, status, result, error, approvalId, toolName } = task; + const elapsedMs = (task.endedAt ?? Date.now()) - task.createdAt; + return { taskId, agent, status, elapsedMs, result, error, approvalId, toolName }; + } +} + +function isActive(task: BackgroundTask): boolean { + return task.status === 'queued' || task.status === 'running'; +} + +/** How a child run that threw is reported: paused for approval, or failed. */ +function failure(error: unknown): Pick { + if (error instanceof SubagentApprovalPause) { + const pending = error.snapshot.pendingToolCall; + return { status: 'awaiting-approval', approvalId: pending.id, toolName: pending.toolName }; + } + return { status: 'failed', error: (error as Error | undefined)?.message ?? String(error) }; +} diff --git a/src/subagents/index.ts b/src/subagents/index.ts index b420aab5a..a4d22706d 100644 --- a/src/subagents/index.ts +++ b/src/subagents/index.ts @@ -1 +1,3 @@ export type { SubagentCatalog, SubagentSummary, Subagents } from './types'; +export { withSubagentOptions } from './backgroundTasks'; +export type { BackgroundTaskStatus, BackgroundTaskView, SubagentOptions } from './backgroundTasks'; diff --git a/src/subagents/subagents.test.ts b/src/subagents/subagents.test.ts index 97ee92760..f9d980dd1 100644 --- a/src/subagents/subagents.test.ts +++ b/src/subagents/subagents.test.ts @@ -6,6 +6,8 @@ import { mockModel } from '../testing'; import type { Message } from '../providers'; import type { SubagentCatalog } from './types'; +const SUBAGENT_TOOLS = ['task', 'agent_status', 'agent_await', 'agent_cancel']; + const task = (agent: string, prompt: string, id?: string) => ({ name: 'task', args: { agent, prompt, description: `${agent} task` }, @@ -36,11 +38,11 @@ describe('subagents option and the task tool (LOU-Y3)', () => { ]); expect(JSON.stringify(researcherModel.calls[0].messages)).not.toContain('secret'); - // The lead sees the sub-agent list and the single `task` tool. + // The lead sees the sub-agent list, the `task` tool and the background-task tools (LOU-Y4). const leadRequest = leadModel.calls[0]; expect(leadRequest.messages[0].content).toContain('## Available sub-agents'); expect(leadRequest.messages[0].content).toContain('- researcher: Finds and summarizes sources'); - expect(leadRequest.tools?.map((t) => t.function.name)).toEqual(['task']); + expect(leadRequest.tools?.map((t) => t.function.name)).toEqual(SUBAGENT_TOOLS); const [toolResult] = toolMessages(result.messages); const text = JSON.parse(toolResult.content as string) as string; @@ -156,7 +158,7 @@ describe('subagents option and the task tool (LOU-Y3)', () => { it('with 2, sub-agents can delegate once more, and the limit holds deeper down', async () => { const { lead, researcherModel, helperModel } = nestedTree(2); const result = await lead.send('go'); - expect(researcherModel.calls[0].tools?.map((t) => t.function.name)).toEqual(['task']); + expect(researcherModel.calls[0].tools?.map((t) => t.function.name)).toEqual(SUBAGENT_TOOLS); // The helper sits at depth 2 = the root's limit: it has sub-agents but no task tool. expect(helperModel.calls[0].tools).toBeUndefined(); expect(toolMessages(researcherModel.calls[1].messages as Message[])[0].content).toContain('helped'); @@ -270,6 +272,10 @@ describe('subagents option and the task tool (LOU-Y3)', () => { expect(() => createAgent({ provider: mockModel([]), tools: [own], subagents: { researcher } })).toThrow( /a tool named 'task' is already registered/ ); + const status = defineTool({ name: 'agent_status', description: 'mine', input: z.object({}), execute: () => 'x' }); + expect(() => createAgent({ provider: mockModel([]), tools: [status], subagents: { researcher } })).toThrow( + /a tool named 'agent_status' is already registered/ + ); }); it('rejects a catalog that lists a sub-agent without a description', async () => { diff --git a/src/subagents/withSubagents.ts b/src/subagents/withSubagents.ts index 3bac4170c..8ed65da62 100644 --- a/src/subagents/withSubagents.ts +++ b/src/subagents/withSubagents.ts @@ -12,13 +12,16 @@ import type { ToolRegistry } from '../tools/ToolRegistry'; import { defineTool, type DefinedTool } from '../tools/defineTool'; import { withPromptTool } from '../skills/withSkills'; import { runSubagent, type SubagentSpec } from '../execution/delegation'; -import { subagentBudget } from '../execution/subagentRuntime'; +import { bindToolCallScope, extendAgent, subagentBudget, toolCallScopeOf } from '../execution/subagentRuntime'; import { isPropagatingToolError } from '../execution/propagatingToolError'; import type { ExecutionResult } from '../execution/AgentExecutor'; import type { SubagentCatalog, SubagentSummary, Subagents } from './types'; +import { BackgroundTasks, subagentOptionsOf } from './backgroundTasks'; /** Name of the tool the lead model delegates with. */ const TASK_TOOL = 'task'; +/** Tools that observe and steer background tasks (LOU-Y4), registered with `task`. */ +const BACKGROUND_TOOLS = ['agent_status', 'agent_await', 'agent_cancel'] as const; /** Same default as `ExecuteOptions.maxSteps`. */ const DEFAULT_MAX_STEPS = 10; @@ -126,7 +129,7 @@ function subagentsPromptBlock(summaries: readonly SubagentSummary[]): string { return [ '## Available sub-agents', '', - `Delegate a self-contained task to one of these with the \`${TASK_TOOL}\` tool. A sub-agent sees only the prompt you give it, not this conversation, so include everything it needs. Several \`${TASK_TOOL}\` calls in one turn run in parallel.`, + `Delegate a self-contained task to one of these with the \`${TASK_TOOL}\` tool. A sub-agent sees only the prompt you give it, not this conversation, so include everything it needs. Several \`${TASK_TOOL}\` calls in one turn run in parallel. With \`background: true\` a task runs while you keep working: check it with \`agent_status\`, collect its answer with \`agent_await\` before you finish, or stop it with \`agent_cancel\`.`, '', ...summaries.map((s) => `- ${s.name}: ${s.description.replace(/\s+/g, ' ').trim()}`), ].join('\n'); @@ -153,13 +156,10 @@ function taskResult(name: string, result: ExecutionResult, maxSteps: number): st return result.text ? `${result.text}\n\n${footer}` : footer; } -async function runTask( - subagents: Subagents, - names: readonly string[], - args: { agent: string; prompt: string; description: string }, - toolOptions: { abortSignal?: AbortSignal } | undefined -): Promise { - const { spec } = await resolveSubagent(subagents, args.agent, names); +type TaskArgs = { agent: string; prompt: string; description: string; background?: boolean }; +type ToolOptions = { abortSignal?: AbortSignal } | undefined; + +async function runTask(spec: SubagentSpec, args: TaskArgs, toolOptions: ToolOptions): Promise { let result: ExecutionResult; try { result = await runSubagent(spec, { @@ -175,7 +175,33 @@ async function runTask( return taskResult(args.agent, result, spec.maxSteps ?? DEFAULT_MAX_STEPS); } -function createTaskTool(subagents: Subagents, summaries: readonly SubagentSummary[]): DefinedTool { +/** The tool options a background child runs with: the same parent run, its own abort signal. */ +function withAbortSignal(toolOptions: ToolOptions, abortSignal: AbortSignal): { abortSignal: AbortSignal } { + const options = { ...toolOptions, abortSignal }; + bindToolCallScope(options, toolCallScopeOf(toolOptions)); + return options; +} + +async function startTask( + subagents: Subagents, + names: readonly string[], + background: BackgroundTasks, + args: TaskArgs, + toolOptions: ToolOptions +): Promise { + const { spec } = await resolveSubagent(subagents, args.agent, names); + if (!args.background) { + return runTask(spec, args, toolOptions); + } + const { taskId, status, agent } = background.start( + args.agent, + (signal) => runTask(spec, args, withAbortSignal(toolOptions, signal)), + toolOptions?.abortSignal + ); + return { taskId, status, agent }; +} + +function createTaskTool(subagents: Subagents, summaries: readonly SubagentSummary[], background: BackgroundTasks): DefinedTool { const names = summaries.map((s) => s.name); return defineTool({ name: TASK_TOOL, @@ -186,16 +212,54 @@ function createTaskTool(subagents: Subagents, summaries: readonly SubagentSummar agent: z.enum(names as [string, ...string[]]).describe('Name of the sub-agent, exactly as listed'), prompt: z.string().describe('Complete instructions for the sub-agent, including all context it needs'), description: z.string().describe('A short (3-5 word) label for this task'), + background: z + .boolean() + .optional() + .describe('true: start the sub-agent and return a taskId at once; collect the answer later with agent_await'), }), - execute: (args, ctx) => runTask(subagents, names, args, ctx), + execute: (args, ctx) => startTask(subagents, names, background, args, ctx), }); } -/** Throws when the agent already has a tool named `task`. */ +function createBackgroundTools(background: BackgroundTasks): DefinedTool[] { + const taskId = z.string().describe('The taskId returned by task with background: true'); + return [ + defineTool({ + name: 'agent_status', + description: 'Status of one background task, or of all of them: queued, running, done, failed, cancelled or awaiting-approval, with elapsedMs.', + input: z.object({ taskId: taskId.optional() }), + execute: ({ taskId: id }) => ({ tasks: background.status(id) }), + }), + defineTool({ + name: 'agent_await', + description: "Waits for background tasks and returns each one's answer (or failure). Tasks still running after timeoutMs report status 'timeout'.", + input: z.object({ + taskId: taskId.optional(), + taskIds: z.array(z.string()).optional().describe('Several taskIds to wait for'), + timeoutMs: z.number().int().positive().optional().describe('Stop waiting after this many milliseconds'), + }), + execute: async (args, ctx) => { + const ids = args.taskIds ?? (args.taskId === undefined ? [] : [args.taskId]); + if (ids.length === 0) throw new Error('agent_await: pass taskId or taskIds.'); + const views = await background.wait(ids, args.timeoutMs, ctx.abortSignal); + return args.taskIds ? { tasks: views } : views[0]; + }, + }), + defineTool({ + name: 'agent_cancel', + description: 'Cancels a queued or running background task.', + input: z.object({ taskId }), + execute: ({ taskId: id }) => background.cancel(id), + }), + ]; +} + +/** Throws when the agent already has a tool named `task` (or one of the background-task tools). */ export function assertNoTaskTool(agent: AgentConfig, toolRegistry: ToolRegistry | undefined): void { - if (toolRegistry?.has(TASK_TOOL) || agent.tools?.[TASK_TOOL]) { + const taken = [TASK_TOOL, ...BACKGROUND_TOOLS].find((name) => toolRegistry?.has(name) || agent.tools?.[name]); + if (taken) { throw new Error( - `subagents: a tool named '${TASK_TOOL}' is already registered, but agents with sub-agents get one automatically. ` + + `subagents: a tool named '${taken}' is already registered, but agents with sub-agents get one automatically. ` + `Rename your tool, or remove the 'subagents' option.` ); } @@ -221,5 +285,12 @@ export async function withSubagents( if (summaries.length === 0) { return { agent, toolRegistry }; } - return withPromptTool(agent, toolRegistry, createTaskTool(subagents, summaries), subagentsPromptBlock(summaries)); + const background = new BackgroundTasks(subagentOptionsOf(subagents).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 }; }