feat(subagent): enrich subagent/start + subagent/end lifecycle events (observe-only)
A hooks bridge translating SubagentStart/SubagentStop needs to know WHICH kind of subagent ran and WHAT it produced — Claude Code's hooks carry subagent_type and the child's final message. Enrich the existing lifecycle emits to match, observe-only: - agentType: an optional caller-supplied subagent-kind label (CC's subagent_type), added to SubagentStartRequest and carried VERBATIM onto both subagent/start (SubagentRunInfo) and subagent/end (SubagentRunEndInfo). The seam never interprets it. dsh-tool-subagent threads it from a new optional Config.agentType, so a deployment exposing multiple subagent kinds (one tool load per kind) labels each. - lastAssistantMessage: the child's final output (SubagentResult.output), added to SubagentRunEndInfo on the settle path so an observer sees what the subagent produced without holding the run. Absent on the reject path (no result produced). Strictly observe-only: both events stay plain emits (subagent/end fires from a detached .then and awaits no listener). A control-flow subagent/end (awaited waterfall returning a decision) would need the emit→waterfall reshape, awaiting listeners before settling, and a provider resume capability — deferred to the background/steering redesign (FIXME(subagent-continuation) anchors it). RFC: implemented/feature/2026-06-30-subagent-observe-enrich.md.
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown>
|
||||
const endInfo = ended.mock.calls[0]![0] as Record<string, unknown>
|
||||
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<string, unknown>
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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<Config> = z.object({
|
||||
@@ -57,6 +65,7 @@ export const Config: z<Config> = 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user