diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index a8afdbd5f1..3a477e171a 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -241,7 +241,7 @@ A subagent run settled — emitted when SubagentRun.result resolves (any stop re 'subagent/end'(info: SubagentRunEndInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:65`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:75`](../../packages/subagent/subagent/src/index.ts) #### `subagent/start` — emit @@ -251,7 +251,7 @@ A subagent run started — emitted after the provider is resolved and its capabi 'subagent/start'(info: SubagentRunInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:59`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:69`](../../packages/subagent/subagent/src/index.ts) ### `system-prompt/*` @@ -455,7 +455,7 @@ list(): string[] start(name: string, request: SubagentStartRequest): SubagentRun ``` -Source: [`packages/subagent/subagent/src/index.ts:103`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:130`](../../packages/subagent/subagent/src/index.ts) ### `ctx.systemPrompt` — `SystemPrompt` diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index c64d370ff2..ca85830808 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -25,6 +25,7 @@ What a caller asks for when starting a subagent. The tool layer builds this from ```ts type-equiv interface SubagentStartRequest { prompt: ContentBlock[] + agentType?: string parent: Agent signal?: AbortSignal agentOptions?: AgentOptions @@ -85,7 +86,7 @@ interface SubagentProvider { } ``` -The service (`ctx.subagents`) emits `subagent/start` when a run begins and `subagent/end` when it settles (see the [events catalog](../cordis-catalog/events-and-services.md)). Both emits contain a thrown listener **per listener** (logged, never propagated): one bad subscriber can neither strand a live run, surface as an unhandled rejection on the detached settle hook, nor starve the listeners registered after it. +The service (`ctx.subagents`) emits `subagent/start` when a run begins and `subagent/end` when it settles (see the [events catalog](../cordis-catalog/events-and-services.md)). Both payloads carry the caller's optional `agentType` label (verbatim from the request — Claude Code's `subagent_type`); `subagent/end` additionally carries `lastAssistantMessage` (the child's final `output`) on the settle path, so an observer sees WHAT the subagent produced without holding the run (absent when the run rejected at the infrastructure level — no result was produced). These are **observe-only** enrichments: both events are plain `emit`s (the `subagent/end` fires from a detached `.then` after the result settles and awaits no listener), so a subscriber observes but cannot change the run. Both emits contain a thrown listener **per listener** (logged, never propagated): one bad subscriber can neither strand a live run, surface as an unhandled rejection on the detached settle hook, nor starve the listeners registered after it. ## In-process backends: depth and seed diff --git a/docs/rfc/README.md b/docs/rfc/README.md index a05a7d5c5c..632b63ff56 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -88,6 +88,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [ACP subagent backend (out-of-process delegation)](implemented/feature/2026-06-22-acp-subagent-backend.md) | 2026-06-22 | | [The `todo_write` tool — model task list as event-sourced session state](implemented/feature/2026-06-29-todo-write-tool.md) | 2026-06-29 | | [Interception seams — the typed-Decision surface a hook programs against](implemented/feature/2026-06-30-interception-seams.md) | 2026-06-30 | +| [Subagent lifecycle enrichment — agentType + lastAssistantMessage (observe-only)](implemented/feature/2026-06-30-subagent-observe-enrich.md) | 2026-06-30 | ### Simplification diff --git a/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md b/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md new file mode 100644 index 0000000000..46cf403666 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md @@ -0,0 +1,29 @@ +# RFC: Subagent lifecycle enrichment — agentType + lastAssistantMessage (observe-only) + +Status: implemented (accepted 2026-06-30) + + + +## Context + +The hooks subsystem ([interception seams RFC](2026-06-30-interception-seams.md)) lets a plugin observe and gate the agent at lifecycle points. Claude Code and Codex both expose **SubagentStart / SubagentStop** hooks, and CC's carry a `subagent_type` (which named subagent kind ran) and the subagent's final message. The harness already emits `subagent/start` and `subagent/end` lifecycle events ([the subagent capability-seam](2026-06-21-subagent-capability-seam.md)), but their payloads were minimal (`provider`, `id`, and on end `stopReason`) — not enough for a hooks bridge to report which KIND of subagent ran, or WHAT it produced, without separately reaching for the live run. + +This RFC enriches those two payloads. It is deliberately **observe-only**: no control-flow change, no waterfall, no `start()` restructure. A run-affecting subagent-stop decision (continuation, injection that changes the run) is a separate, larger redesign and stays out of scope. + +## Decision + +Add two pieces of information to the subagent lifecycle surface: + +1. **`agentType` — a caller-supplied subagent-kind label**, the harness analogue of CC's `subagent_type`. It is optional on `SubagentStartRequest`, carried VERBATIM onto both `subagent/start` (`SubagentRunInfo`) and `subagent/end` (`SubagentRunEndInfo`). The seam never interprets it. The model-facing `dsh-tool-subagent` tool threads it from a new optional `Config.agentType`, so a deployment that exposes multiple subagent kinds (one tool load per kind) labels each. Absent when the caller does not distinguish kinds (the spread omits the key — `exactOptionalPropertyTypes`-correct). + +2. **`lastAssistantMessage` — the child's final output**, added to `SubagentRunEndInfo`. On the settle path it is `SubagentResult.output` (so an observer sees WHAT the subagent produced without holding the run). On the REJECT path (an infrastructure fault where no `SubagentResult` was produced — the seam only knows `stopReason: 'error'`) it is absent. + +Both events stay plain **`emit`s**. `subagent/end` fires from a detached `.then` on `run.result` and awaits no listener, so it is genuinely observe-only by construction — a `subagent/start` listener can still reach the live child via `ctx.agents.get(info.id)` and `inject()` into it; a `subagent/end` listener can only observe (the run has settled). Per-listener containment (already in place) keeps one bad subscriber from stranding a live run or surfacing as an unhandled rejection on the detached settle hook. + +## Why observe-only, and what is deferred + +A control-flow `subagent/end` (an awaited waterfall returning a stop/continue decision, like the other interception seams) would require: reshaping `subagent/end` from emit to waterfall, restructuring `SubagentService.start` to await listeners before settling, and implementing the `resume` capability in the in-process provider so a "continue" can actually re-run the child. That belongs to the background/steering subagent redesign the [capability-seam RFC](2026-06-21-subagent-capability-seam.md) already defers (the same redesign that unifies long-running-tool handling across subagents and bash). This RFC ships the observe-only enrichment a hooks bridge needs today; `FIXME(subagent-continuation)` / `TODO` anchors mark where the control-flow version would land if and when that redesign happens. + +## Consequences + +A hooks bridge (or a native plugin) can now translate SubagentStart/SubagentStop faithfully: it reports `agentType`, matches its hook config on it, and forwards the child's `lastAssistantMessage` to a SubagentStop handler — all by subscribing to the existing emits, no new control-flow surface. The vocabulary addition is documented in [docs/core-data-structures/subagent.md](../../../core-data-structures/subagent.md) (the `SubagentStartRequest` type-equiv block + the events prose) and the two subagent READMEs; the catalog is regenerated. No production behavior changes — the events fire exactly as before, with two more (optional) fields on their payloads — so no snapshot or e2e change is needed. diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 57862ca8ab..7d6c58af3c 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -32,6 +32,8 @@ Unlike the bash seam (one executor per context, second load throws), **multiple `provider.start(request)` returns a `SubagentRun`: a handle with a `result` promise, `cancel()`, `dispose()`, and the optional runtime methods. `result` resolves with a `SubagentResult` (`output`, optional `structured`, `stopReason`) — it does **not** reject on a child-level failure (a model/transport failure resolves with `stopReason: 'error'`), so the consumer maps a non-`completed` reason to an `isError` tool result. The consumer MUST `dispose()` on every path (success, error, abort) to reach child quiescence and avoid leaking an idle child / session. +The service emits `subagent/start` (payload `SubagentRunInfo`) and `subagent/end` (payload `SubagentRunEndInfo`) around the run — both **observe-only** (plain `emit`s; `subagent/end` fires from a detached `.then` and awaits no listener). Both payloads carry the request's optional `agentType` label (Claude Code's `subagent_type`, verbatim — the seam never interprets it); `subagent/end` additionally carries `lastAssistantMessage` (the child's final `output`) on the settle path, absent when the run rejected at the infrastructure level. A `subagent/start` listener can still reach the live child via `ctx.agents.get(info.id)`; a `subagent/end` listener can only observe (the run has settled). Any run-affecting decision (continuation, injection that changes the run) is out of scope for this observe-only surface. + ## Scope (first cut) The consumer collects **synchronously**: it starts a run and awaits `result`. Steering (`sendMessage`) is part of the contract but intentionally unused. Background / poll / spill semantics are deferred to a future redesign unifying long-running-tool handling across subagents and bash. See the RFC: [docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md). diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 356ad60a00..916c53e759 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -20,11 +20,21 @@ * semantics are deferred to a future redesign that unifies long-running-tool * handling across subagents and bash. * + * The `subagent/start` / `subagent/end` lifecycle events carry an enriched but + * OBSERVE-ONLY payload (`agentType`, and on end `lastAssistantMessage`) — see + * `docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md`. + * FIXME(subagent-continuation): a control-flow `subagent/end` (an awaited + * waterfall returning a stop/continue decision, like the other interception + * seams) would require reshaping this emit into a waterfall, awaiting listeners + * before settling, and a `resume` capability on the in-process provider — part + * of the deferred background/steering redesign, NOT this observe-only cut. + * * @module @deepseek-ai/dsh-subagent */ import { Context, Service } from 'cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { AgentId } from '@deepseek-ai/dsh-agent' import type { SubagentCapabilities, @@ -72,6 +82,13 @@ export interface SubagentRunInfo { provider: string /** The child agent's id. */ id: AgentId + /** + * The caller's subagent-kind label, carried verbatim from + * {@link SubagentStartRequest.agentType} (Claude Code's `subagent_type`). + * Absent when the caller did not supply one. An observer (a hooks bridge, + * a UI) reports or matches on it; the seam never interprets it. + */ + agentType?: string } /** Outcome detail for a settled subagent run (the `subagent/end` payload). */ @@ -80,8 +97,18 @@ export interface SubagentRunEndInfo { provider: string /** The child agent's id. */ id: AgentId + /** The caller's subagent-kind label (see {@link SubagentRunInfo.agentType}). */ + agentType?: string /** The terminal stop reason. */ stopReason: SubagentResult['stopReason'] + /** + * The child's final assistant output ({@link SubagentResult.output}), carried + * onto the end event so an observer sees WHAT the subagent produced without + * holding the run. Absent when the run rejected at the infrastructure level + * (no {@link SubagentResult} was produced — the seam only knows `stopReason: + * 'error'`). + */ + lastAssistantMessage?: ContentBlock[] } /** @@ -160,17 +187,22 @@ export class SubagentService extends Service { // acceptable. `ctx.emit` halts the dispatch on the first throw, so a single // surrounding try/catch is not enough — each listener is invoked and // contained individually. - this.emitLifecycle('subagent/start', { provider: name, id: run.id }) + // Carry the caller's subagent-kind label verbatim onto both lifecycle events + // (absent when not supplied — the spread omits the key for exactOptionalPropertyTypes). + const agentType = request.agentType !== undefined ? { agentType: request.agentType } : {} + this.emitLifecycle('subagent/start', { provider: name, id: run.id, ...agentType }) // Emit `subagent/end` when the run settles. The result promise does not // reject on a child-level failure (it resolves with stopReason 'error'), // so a rejection here is an infrastructure fault — surface its stop reason // as 'error' for the telemetry event without swallowing the rejection - // (the consumer still observes it via `run.result`). Per-listener - // containment also keeps a thrown `subagent/end` listener from becoming an - // unhandled rejection on this detached `.then`. + // (the consumer still observes it via `run.result`). On the resolve path the + // child's final output rides on the event (lastAssistantMessage); on the + // reject path there is no SubagentResult, so only the stop reason is known. + // Per-listener containment also keeps a thrown `subagent/end` listener from + // becoming an unhandled rejection on this detached `.then`. void run.result.then( - (result) => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: result.stopReason }) }, - () => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }) }, + (result) => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, ...agentType, stopReason: result.stopReason, lastAssistantMessage: result.output }) }, + () => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, ...agentType, stopReason: 'error' }) }, ) return run } diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index fb60d5667c..55ef04a8a9 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -40,6 +40,15 @@ export interface SubagentCapabilities { export interface SubagentStartRequest { /** The task/prompt for the child agent (a user message in the child session). */ prompt: ContentBlock[] + /** + * Optional caller-supplied LABEL for the kind of subagent (e.g. `code-reviewer`, + * `researcher`) — the harness analogue of Claude Code's `subagent_type`. The + * seam does not interpret it; it is carried verbatim onto the `subagent/start` + * and `subagent/end` lifecycle events so an observer (a hooks bridge, a UI) can + * report or match on which kind of subagent ran. Absent when the caller does + * not distinguish subagent kinds. + */ + agentType?: string /** * The spawning ("parent") agent — the one whose tool call started this * subagent. REQUIRED: in-process backends read `parent.session.header` for diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 6876c6cd80..e23ae4ff3b 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -173,6 +173,82 @@ describe('SubagentService', () => { expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id, stopReason: 'completed' })) }) + it('carries agentType (from the request) onto both lifecycle events, and lastAssistantMessage onto end', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider(new StubProvider( + 'enriched', + ALL_CAPS, + { output: [{ type: 'text', text: 'the child answer' }], stopReason: 'completed' }, + )) + + const started = vi.fn() + const ended = vi.fn() + ctx.on('subagent/start', started) + ctx.on('subagent/end', ended) + + const run = ctx.subagents.start('enriched', baseRequest({ agentType: 'code-reviewer' })) + expect(started).toHaveBeenCalledWith(expect.objectContaining({ provider: 'enriched', id: run.id, agentType: 'code-reviewer' })) + + await run.result + await Promise.resolve() + expect(ended).toHaveBeenCalledWith(expect.objectContaining({ + provider: 'enriched', + id: run.id, + agentType: 'code-reviewer', + stopReason: 'completed', + lastAssistantMessage: [{ type: 'text', text: 'the child answer' }], + })) + }) + + it('omits agentType when the request supplied none', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider(new StubProvider('plain')) + + const started = vi.fn() + const ended = vi.fn() + ctx.on('subagent/start', started) + ctx.on('subagent/end', ended) + + const run = ctx.subagents.start('plain', baseRequest()) + await run.result + await Promise.resolve() + + const startInfo = started.mock.calls[0]![0] as Record + const endInfo = ended.mock.calls[0]![0] as Record + expect('agentType' in startInfo).toBe(false) + expect('agentType' in endInfo).toBe(false) + // lastAssistantMessage IS present on a resolved end (the child's output). + expect(endInfo.lastAssistantMessage).toEqual([{ type: 'text', text: 'ok' }]) + }) + + it('omits lastAssistantMessage on the reject path (no SubagentResult was produced)', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'rej', + capabilities: NO_CAPS, + start: () => ({ + id: AgentId('rej-child'), + result: Promise.reject(new Error('infra fault')), + cancel() {}, + dispose: async () => {}, + }), + }) + + const ended = vi.fn() + ctx.on('subagent/end', ended) + const run = ctx.subagents.start('rej', baseRequest({ agentType: 'researcher' })) + await run.result.catch(() => {}) + await Promise.resolve() + + const endInfo = ended.mock.calls[0]![0] as Record + expect(endInfo.stopReason).toBe('error') + expect(endInfo.agentType).toBe('researcher') // agentType still carried on reject + expect('lastAssistantMessage' in endInfo).toBe(false) // but no output exists + }) + it('emits subagent/end with stopReason "error" when the run result promise rejects', async () => { const ctx = new Context() await ctx.plugin(SubagentService) diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 1bb48f29ff..996e44d96e 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -11,6 +11,7 @@ This plugin binds to **exactly one** provider (`Config.provider`). The model see | `provider` (required) | The `ctx.subagents` provider name to start runs on (`spawn`, `fork`, `acp`, …). | | `toolName` | The model-facing tool name to register (default `subagent`). Set a distinct value per load when exposing multiple providers, e.g. `subagent` + `subagent_acp`. | | `agentOptions` | Default per-child `{ model?, systemPrompt? }` applied to every spawned child. | +| `agentType` | Optional subagent-kind label (Claude Code's `subagent_type`) stamped on every run's `subagent/start`/`subagent/end` events, so an observer (a hooks bridge, a UI) can report or match on which kind ran. Set a distinct value per load when exposing multiple subagent kinds. | ## Lifecycle (synchronous collect) diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 05490127ea..cb92035e88 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -48,6 +48,14 @@ export interface Config { * spawned child. Omitted fields fall back to the child loop's own defaults. */ agentOptions?: AgentOptions + /** + * Optional subagent-kind LABEL stamped on every run this tool starts (Claude + * Code's `subagent_type`). Carried onto the `subagent/start`/`subagent/end` + * lifecycle events so an observer can report or match on which kind of + * subagent ran. A deployment that exposes multiple subagent kinds (one tool + * load per kind) sets a distinct `agentType` per load; omit when undifferentiated. + */ + agentType?: string } export const Config: z = z.object({ @@ -57,6 +65,7 @@ export const Config: z = z.object({ model: z.string(), systemPrompt: z.string(), }), + agentType: z.string(), }) /** @@ -128,6 +137,7 @@ export function apply(ctx: Context, config: Config): void { parent, ...exec.signal ? { signal: exec.signal } : {}, ...config.agentOptions ? { agentOptions: config.agentOptions } : {}, + ...config.agentType !== undefined ? { agentType: config.agentType } : {}, } const run: SubagentRun = ctx.subagents.start(config.provider, request) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 2f40cd6f8c..0b088b65db 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -153,6 +153,60 @@ describe('dsh-tool-subagent', () => { expect(seen?.agentOptions).toEqual({ model: 'child-model', systemPrompt: 'be terse' }) }) + it('forwards a configured agentType into the start request (observed on the lifecycle events)', async () => { + let seen: { agentType?: string } | undefined + const starts: { agentType?: string }[] = [] + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + ctx.on('subagent/start', info => void starts.push(info)) + ctx.subagents.registerProvider({ + name: 'typed', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + start: (request) => { + seen = request + return { + id: AgentId('typed-child'), + result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), + cancel() {}, + dispose: async () => {}, + } + }, + }) + await ctx.plugin(tool, { provider: 'typed', agentType: 'code-reviewer' }) + + await callSubagent(ctx, { description: 'd', prompt: 'p' }) + // The config agentType reaches the request, and the service stamps it on the event. + expect(seen?.agentType).toBe('code-reviewer') + expect(starts[0]?.agentType).toBe('code-reviewer') + }) + + it('omits agentType from the request when none is configured', async () => { + let seen: { agentType?: string } | undefined + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'untyped', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + start: (request) => { + seen = request + return { + id: AgentId('untyped-child'), + result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), + cancel() {}, + dispose: async () => {}, + } + }, + }) + await ctx.plugin(tool, { provider: 'untyped' }) + + await callSubagent(ctx, { description: 'd', prompt: 'p' }) + expect(seen !== undefined && 'agentType' in seen).toBe(false) + }) + it('defaults toolName and omits agentOptions when apply() is called directly (schema bypass)', async () => { // `ctx.plugin` validates+defaults config first (toolName→'subagent', the // agentOptions object→{}), so the runtime `?? 'subagent'` fallback and the