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:
Tianyi Cui
2026-06-30 21:29:08 +08:00
parent 60418a5779
commit 7cc7b9cf7f
11 changed files with 225 additions and 10 deletions
@@ -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