refactor(subagent): drop the agentType lifecycle field
Address review: `agentType` was a Claude-Code concept (`subagent_type`) that does not fit our own subagent seam — nothing in the harness interprets it, and its only consumer was the CC-dialect hook bridge. Rather than let a foreign concept sit on the core seam, remove it: - `SubagentStartRequest`, `SubagentRunInfo`, `SubagentRunEndInfo`: drop the `agentType` field; the `subagent/start`/`subagent/end` payloads now carry `provider`/`id` (+ end `stopReason`/`lastAssistantMessage`) only. - `dsh-tool-subagent`: drop `Config.agentType` and its request plumbing. - Tests: keep the lastAssistantMessage / clone-containment / reject-path coverage (rewritten to not assert agentType); delete the two tool-subagent tests that only exercised agentType forwarding (dead behavior). - Docs: retitle + rewrite the subagent-observe-enrich RFC to the one shipped enrichment (lastAssistantMessage), with a note on why agentType was dropped; update rfc/README index title, both subagent READMEs, and the core-data-structures/subagent.md type-equiv block + prose; regenerate catalog. The CC bridge (PR-F) will feed Claude Code's own default matcher value "general-purpose" for its SubagentStart/Stop agent_type matcher instead.
This commit is contained in:
@@ -445,7 +445,7 @@ list(): string[]
|
||||
start(name: string, request: SubagentStartRequest): SubagentRun
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:130`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:121`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
### `ctx.systemPrompt` — `SystemPrompt`
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ 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
|
||||
@@ -86,7 +85,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 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.
|
||||
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)). `subagent/end` 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** events: both 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
|
||||
|
||||
|
||||
+1
-1
@@ -87,7 +87,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 |
|
||||
| [Subagent lifecycle enrichment — lastAssistantMessage (observe-only)](implemented/feature/2026-06-30-subagent-observe-enrich.md) | 2026-06-30 |
|
||||
|
||||
### Simplification
|
||||
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
# RFC: Subagent lifecycle enrichment — agentType + lastAssistantMessage (observe-only)
|
||||
# RFC: Subagent lifecycle enrichment — lastAssistantMessage (observe-only)
|
||||
|
||||
Status: implemented (accepted 2026-06-30)
|
||||
|
||||
<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. -->
|
||||
<!-- An earlier draft also added an `agentType` subagent-kind label (the harness
|
||||
analogue of CC's `subagent_type`) to the request + both lifecycle payloads.
|
||||
It was dropped in review: it is a Claude-Code concept that does not fit our
|
||||
own seam (nothing here interprets it, and the only consumer was a CC-dialect
|
||||
bridge). The CC bridge instead feeds Claude Code's own default matcher value
|
||||
`"general-purpose"` for its SubagentStart/Stop `agent_type` matcher. So this
|
||||
RFC ships ONE enrichment: `lastAssistantMessage`. -->
|
||||
|
||||
## 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.
|
||||
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 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 WHAT a subagent 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.
|
||||
This RFC enriches the end payload. 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 a DEEP CLONE of `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. The clone is load-bearing for observe-only: the `subagent/end` emit fires from a detached `.then` registered *before* `start()` returns, i.e. before the caller's own `await run.result` continuation — handing listeners the same array reference would let a mutating listener corrupt the caller's `SubagentResult.output`. `structuredClone` makes the event a read-only view (a regression test mutates the event's array and asserts the caller's result is untouched).
|
||||
**Add `lastAssistantMessage` — the child's final output — to `SubagentRunEndInfo`.** On the settle path it is a DEEP CLONE of `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. The clone is load-bearing for observe-only: the `subagent/end` emit fires from a detached `.then` registered *before* `start()` returns, i.e. before the caller's own `await run.result` continuation — handing listeners the same array reference would let a mutating listener corrupt the caller's `SubagentResult.output`. `structuredClone` makes the event a read-only view (a regression test mutates the event's array and asserts the caller's result is untouched); a clone failure is contained (logged, the event still fires without `lastAssistantMessage`) rather than becoming an unhandled rejection on the detached `.then`.
|
||||
|
||||
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.
|
||||
|
||||
@@ -26,4 +29,4 @@ A control-flow `subagent/end` (an awaited waterfall returning a stop/continue de
|
||||
|
||||
## 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.
|
||||
A hooks bridge (or a native plugin) can now forward the child's `lastAssistantMessage` to a SubagentStop handler 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 events prose) and the two subagent READMEs; the catalog is regenerated. No production behavior changes — the events fire exactly as before, with one more (optional) field on the end payload — so no snapshot or e2e change is needed.
|
||||
@@ -32,7 +32,7 @@ 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` (a deep clone of the child's final `output`) on the settle path, absent when the run rejected at the infrastructure level. The clone keeps the surface observe-only: the end emit fires from a detached `.then` before the caller's `await run.result` resumes, so a shared reference would let a mutating listener corrupt the caller's result. 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.
|
||||
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). `subagent/end` carries `lastAssistantMessage` (a deep clone of the child's final `output`) on the settle path, absent when the run rejected at the infrastructure level. The clone keeps the surface observe-only: the end emit fires from a detached `.then` before the caller's `await run.result` resumes, so a shared reference would let a mutating listener corrupt the caller's result. 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)
|
||||
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
* 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`.
|
||||
* The `subagent/start` / `subagent/end` lifecycle events carry an OBSERVE-ONLY
|
||||
* payload; `subagent/end` additionally carries the child's `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
|
||||
@@ -82,13 +82,6 @@ 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). */
|
||||
@@ -97,8 +90,6 @@ 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']
|
||||
/**
|
||||
@@ -187,10 +178,7 @@ 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.
|
||||
// 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 })
|
||||
this.emitLifecycle('subagent/start', { provider: name, id: run.id })
|
||||
// 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
|
||||
@@ -220,9 +208,9 @@ export class SubagentService extends Service {
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`subagent: could not clone ${name} output for subagent/end: ${String(error)}`)
|
||||
}
|
||||
this.emitLifecycle('subagent/end', { provider: name, id: run.id, ...agentType, stopReason: result.stopReason, ...lastAssistantMessage !== undefined ? { lastAssistantMessage } : {} })
|
||||
this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: result.stopReason, ...lastAssistantMessage !== undefined ? { lastAssistantMessage } : {} })
|
||||
},
|
||||
() => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, ...agentType, stopReason: 'error' }) },
|
||||
() => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }) },
|
||||
)
|
||||
return run
|
||||
}
|
||||
|
||||
@@ -40,15 +40,6 @@ 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,7 +173,7 @@ 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 () => {
|
||||
it('carries lastAssistantMessage (the child output) onto the end event', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider(new StubProvider(
|
||||
@@ -187,42 +187,19 @@ describe('SubagentService', () => {
|
||||
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' }))
|
||||
const run = ctx.subagents.start('enriched', baseRequest())
|
||||
expect(started).toHaveBeenCalledWith(expect.objectContaining({ provider: 'enriched', id: run.id }))
|
||||
|
||||
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('observe-only: a subagent/end listener mutating lastAssistantMessage cannot corrupt the caller\'s result', async () => {
|
||||
// The subagent/end emit fires from a detached `.then` registered before
|
||||
// start() returns — i.e. BEFORE the caller's own `await run.result`
|
||||
@@ -267,14 +244,13 @@ describe('SubagentService', () => {
|
||||
|
||||
const ended = vi.fn()
|
||||
ctx.on('subagent/end', ended)
|
||||
const run = ctx.subagents.start('rej', baseRequest({ agentType: 'researcher' }))
|
||||
const run = ctx.subagents.start('rej', baseRequest())
|
||||
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
|
||||
expect('lastAssistantMessage' in endInfo).toBe(false) // no output exists on reject
|
||||
})
|
||||
|
||||
it('contains a structuredClone failure: emits subagent/end without lastAssistantMessage (no unhandled rejection)', async () => {
|
||||
@@ -282,7 +258,7 @@ describe('SubagentService', () => {
|
||||
// containment. An uncloneable output (here a content block carrying a
|
||||
// function) would otherwise throw and become an unhandled rejection on the
|
||||
// detached `.then`. The handler must instead log and emit the event WITHOUT
|
||||
// lastAssistantMessage, still carrying the real stopReason/agentType.
|
||||
// lastAssistantMessage, still carrying the real stopReason.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
const warn = vi.fn(); ctx.logger.warn = warn as never
|
||||
@@ -301,13 +277,12 @@ describe('SubagentService', () => {
|
||||
|
||||
const ended = vi.fn()
|
||||
ctx.on('subagent/end', ended)
|
||||
const run = ctx.subagents.start('unclone', baseRequest({ agentType: 'researcher' }))
|
||||
const run = ctx.subagents.start('unclone', baseRequest())
|
||||
await run.result
|
||||
await Promise.resolve()
|
||||
|
||||
const endInfo = ended.mock.calls[0]![0] as Record<string, unknown>
|
||||
expect(endInfo.stopReason).toBe('completed') // the real outcome is preserved
|
||||
expect(endInfo.agentType).toBe('researcher')
|
||||
expect('lastAssistantMessage' in endInfo).toBe(false) // clone failed → omitted, not crashed
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('could not clone'))
|
||||
})
|
||||
|
||||
@@ -11,7 +11,6 @@ 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,14 +48,6 @@ 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({
|
||||
@@ -65,7 +57,6 @@ export const Config: z<Config> = z.object({
|
||||
model: z.string(),
|
||||
systemPrompt: z.string(),
|
||||
}),
|
||||
agentType: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -137,7 +128,6 @@ 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,60 +153,6 @@ 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