The first OUT-OF-PROCESS subagent backend, proving the seam generalizes past the in-process backends. @deepseek-ai/dsh-subagent-acp runs each child agent in a spawned subprocess, driven over the Agent Client Protocol as the CLIENT — the direction-inverted twin of the dsh-acp server bridge. Point the configured command at the acp-agent example and the harness talks to its own process. - Fresh process per run: start spawns, runs one ACP session (initialize → newSession → prompt), dispose kills the subprocess and awaits its exit. - Minimal client stub: advertises no fs/terminal; accumulates agent_message_chunk text as the result output; auto-answers session/request_permission by a configured policy (reject default / allow). No start-time capabilities (an out-of-process child can't enforce the parent's depth/tool-filter); ignores request.parent; injects only `subagents`. - StopReason mapping (end_turn→completed, cancelled→aborted, …); result resolves error/aborted on a child failure, never rejects (seam contract). - Security: credential-shaped ambient env vars are scrubbed; the child's own key is forwarded only via explicit config.env. A spawn-level error (ENOENT) is captured and raced against the ACP drive so a bad command settles error rather than crashing the parent. Testing designed at every tier: keyless integration drives a scripted mock ACP server subprocess (cancellation incl. the pre-newSession race and a torn-pipe-after-cancel, permission auto-answer, non-message updates, spawn failure, HMR, export shape) at 100% coverage; a with-key e2e drives the REAL acp-agent example process (PONG + real file write, verified on disk) — the harness driving itself. Snapshot coverage of an ACP child is deferred as TODO(acp-subagent-replay) (each child is its own process with its own replay). Stayed on @agentclientprotocol/sdk 0.25.1: the proposed 0.28.x bump only deprecates the stable ClientSideConnection/AgentSideConnection API this layer uses (33 sites incl. the server bridge), turning no-deprecated red across code this PR shouldn't rewrite — that fluent-API migration is its own follow-up. The backend needs nothing 0.28.x adds. This completes the subagent seam stack (PR1 interface → PR2 in-process → PR2.5 snapshot infra → PR3 ACP); the seam RFC moves to implemented/, amended.
3.6 KiB
@deepseek-ai/dsh-subagent
The subagent seam: an abstract SubagentService (ctx.subagents) for an agent delegating work to another agent. A subagent is a child agent; a SubagentProvider is one transport for running it.
This package is the interface third of the capability seam, split so each concern evolves (and swaps) independently:
| Package | Role |
|---|---|
@deepseek-ai/dsh-subagent (this) |
the interface: registry service + vocabulary types |
@deepseek-ai/dsh-subagent-spawn |
an implementation: fresh in-process child |
@deepseek-ai/dsh-subagent-fork |
an implementation: in-process child seeded from the parent's log |
@deepseek-ai/dsh-subagent-acp |
an implementation: ACP client driving another process |
@deepseek-ai/dsh-tool-subagent |
the model-facing tool over ctx.subagents |
Unlike the bash seam (one executor per context, second load throws), multiple providers coexist here. Each registers under a unique name and a caller picks one by name — the shape mirrors the LLM adapter registry (LlmService.registerAdapter), not the single-service bash executor. This is the requirement that rules out the bash shape: an agent may want an in-process child for a cheap subtask and an out-of-process ACP child for an isolated one, in the same runtime.
Service API (ctx.subagents)
| Member | Semantics |
|---|---|
registerProvider(provider) |
Register under provider.name. Throws SubagentError('DUPLICATE_PROVIDER') on a name clash. Effect-scoped (HMR-safe); returns the disposer. |
getProvider(name) |
Look up a provider (undefined if absent). |
list() |
Registered provider names (insertion order). |
start(name, request) |
Resolve the provider (NO_PROVIDER if absent), validate every requested START-TIME capability (UNSUPPORTED_CAPABILITY for the first unmet one — before any child is created), then delegate to provider.start and emit subagent/start / subagent/end around the run. |
Capabilities: two kinds, discovered two ways
- Start-time features (
outputSchema,depthLimit,toolFilter) are a staticprovider.capabilitiesdescriptor, checked by the service BEFORE a run exists. A request that needs one the provider lacks is rejected loud (UNSUPPORTED_CAPABILITY), never accepted-then-ignored. - Runtime features (steering, resume) are optional methods on
SubagentRun(sendMessage?,resume?). The method's presence IS the capability; TS narrowing is the discovery mechanism — a consumer cannot call an absent method without narrowing first, so there is no silent degradation path.
Run lifecycle
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.
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.
See src/types.ts for the full contracts.