Beyond the mechanical conflicts (provider capability lines vs master's new inheritsParentContext field; generated catalogs regenerated rather than hand-merged; knip/lockfile), three master-side reworks required semantic adaptation of this branch: - The persona rework removed AgentOptions.systemPrompt, which was the structured-output instruction's channel. The instruction now rides the SAME final-request enforcement listener that injects the schema'd tool: appended per request to final.system (per-request wire state, not agent prompt state). Tests assert the wire request (adapter.requests) instead of child.options; the bare-direct-dispatch test pins the no-system arm. - Tool guidance moved out of deployment prompts into per-tool prompt sections; the examples' workflow paragraph became a tool:<toolName> section contributed by dsh-tool-workflow (explicit-ask-only policy), and both example personas resolve to master's minimal identity+behavior form. tool-workflow gains inject: systemPrompt (+ peer dep, tsconfig ref); the export-shape guard updated. - The uniform-RFC-format gate: the dynamic-workflows RFC restructured to the implemented/ skeleton (bare Status line; Proposal -> Decision; What-was-rejected -> Alternatives considered; new Consequences), and the overall-run-timeout deferral is now recorded in the RFC's Deferred list. The doc-graphs atlas classification gains the workflows seam (workflow-vm implementation, tool-workflow consumer). Master's harness-identity section made "empty assembled prompt" states unreachable through the loop, so the instruction-append is a plain undefined-ternary and the structured tests assert append-not-replace. All snapshot goldens (including workflow-run) replay unchanged. Full local CI-equivalent gate sequence green on the merged tree.
7.4 KiB
Subagent
The subagent seam — an agent delegating work to a child agent. Like bash it is one optional capability, not part of the agent-loop spine, so its vocabulary lives here rather than in core.md. But it differs from every other seam on one axis: multiple provider implementations coexist in one context, registered by name (ctx.subagents), where bash allows only one executor. The registry shape mirrors the LLM adapter registry, not the single-service bash executor.
Interface: dsh-subagent (ctx.subagents + the vocabulary below). Implementations are sibling packages (dsh-subagent-spawn, -fork, -acp); the model-facing consumer is dsh-tool-subagent. The proposal and rationale: the subagent RFC.
Source: packages/subagent/subagent/src/types.ts
Two kinds of capability, discovered two ways
A provider advertises its start-time features on a static descriptor the service checks BEFORE a run exists; a request that needs one the provider lacks is rejected loud (SubagentError('UNSUPPORTED_CAPABILITY')), never accepted-then-ignored. Runtime features (steering, resume) are instead optional methods on SubagentRun — the method's presence IS the capability, and TS narrowing is the discovery mechanism.
interface SubagentCapabilities {
outputSchema: boolean
depthLimit: boolean
toolFilter: boolean
}
The start request
What a caller asks for when starting a subagent. The tool layer builds this from the model's { description, prompt } plus its own config; the service validates the start-time capabilities against the named provider, then passes it to provider.start. parent is REQUIRED — in-process backends read parent.session.header for the working directory, the parentSession lineage, and the delegation depth. The three optional fields (outputSchema, maxDepth, toolFilter) each gate on the matching SubagentCapabilities flag. outputSchema is an object-rooted JSON Schema within the subset assertSupportedOutputSchema (dsh-tools) enforces — a schema outside it is rejected loud at start; the in-process backends realize it with a forced structured_output capture tool (see the driver README).
interface SubagentStartRequest {
prompt: ContentBlock[]
parent: Agent
signal?: AbortSignal
agentOptions?: AgentOptions
outputSchema?: StructuredOutputSchema
maxDepth?: number
toolFilter?: { allow?: string[]; deny?: string[] }
}
The terminal result: SubagentResult
The outcome of a run, resolved by SubagentRun.result. structured is present iff the request carried an outputSchema AND the provider honored it. A non-completed stopReason means output may be partial — the consumer maps it to an isError tool result rather than reporting partial output as success.
interface SubagentResult {
output: ContentBlock[]
structured?: unknown
stopReason: SubagentStopReason
}
SubagentStopReason is a merge-extensible derived union — a backend may add variants, so consumers branch on the known cases and treat an unknown terminal reason as a failure:
interface SubagentStopReasonMap {
completed: 'completed'
aborted: 'aborted'
error: 'error'
'max-tokens': 'max-tokens'
refusal: 'refusal'
}
A live run: SubagentRun
The handle the consumer holds while a child executes. The consumer awaits result, may cancel mid-flight, and MUST dispose on every path to reach child quiescence (no leaked idle child / session). result 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 result; it rejects only on an infrastructure fault the seam cannot represent. sendMessage and resume are OPTIONAL: a provider that supports the runtime capability defines the method; one that doesn't omits it.
interface SubagentRun {
readonly id: AgentId
readonly result: Promise<SubagentResult>
cancel(reason?: string): void
dispose(): Promise<void>
sendMessage?(content: ContentBlock[]): void
resume?(content: ContentBlock[]): SubagentRun
}
The provider seam: SubagentProvider
One transport for running a child agent. Implementations register under a unique name via SubagentService.registerProvider; multiple coexist in one context. The service validates every requested start-time capability before calling start, so an implementation may assume e.g. request.maxDepth is honorable when present. inheritsParentContext is a DESCRIPTIVE fact beside the capabilities (nothing validates against it): whether a child sees the parent conversation (fork: true, spawn/acp: false) — the model-facing consumer derives truthful tool wording from it.
interface SubagentProvider {
readonly name: string
readonly capabilities: SubagentCapabilities
readonly inheritsParentContext: boolean
start(request: SubagentStartRequest): SubagentRun
}
The service (ctx.subagents) emits subagent/start when a run begins and subagent/end when it settles (see the events catalog). 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 emits (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
The two in-process backends (dsh-subagent-spawn fresh, dsh-subagent-fork seeded) run the child as a child Agent on the same context via ctx.agents.create. Two pieces of vocabulary ride on the existing agent/session types rather than new core types:
- Delegation depth is a merge-extensible
AgentOptions.subagentDepthfield (0for a top-level agent, parent + 1 for a child). The seam owns it — the loop neither sets nor reads it — so a nested spawn reads its parent's depth fromparent.options.subagentDepthand thedepthLimitcapability caps the tree by refusing a child whose depth would exceedrequest.maxDepth. - Fork seeding uses
CreateAgentOptions.seed(aSessionEvent[]prefix threaded throughAgentLoop.createAgent→ctx.sessions.prepare({ seed }), the same primitiveresumeuses). The fork backend passes a balanced completed-turn prefix of the parent's log — the parent's events up to and including its lastturn/end— so the seed is contiguous-from-0 and the invariants replay accepts it (the in-flight, unbalanced turn is excluded).