Add subagent capability seam: interface, mock backend, model-facing tool

Introduce the `packages/subagent/` group and the abstract subagent seam — an
agent delegating to a child agent — as a named-provider registry (`ctx.subagents`),
unlike the single-implementation bash seam, so multiple transports (in-process,
ACP, future A2A) coexist. This first PR lands the interface, a scripted test
backend, and the model-facing tool, validated through the real cordis load path.

- dsh-subagent: SubagentService registry + SubagentProvider/SubagentRun
  vocabulary + subagent/start|end events. Start-time capabilities (outputSchema,
  depthLimit, toolFilter) are checked pre-start and rejected loud; runtime
  capabilities (sendMessage, resume) are optional methods on SubagentRun.
- dsh-subagent-mock (support): scripted provider for keyless, deterministic
  tests through the real Loader/export path.
- dsh-tool-subagent: the model-facing `subagent` tool, config-bound to one
  provider; synchronous collect with try/finally dispose, signal->cancel
  bridging, and non-completed-stop-reason -> isError mapping.
- Proposed RFC documenting the seam, the fork-vs-spawn-as-separate-backends
  decision, own-session isolation, synchronous-collect scope, and the deferral
  of background/poll/spill to a future unification with bash.
- Wire the new group into tsconfigs, build refs, package hierarchy docs, the
  module graph, and the cordis catalog.

RFC: docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md
This commit is contained in:
Tianyi Cui
2026-06-21 22:31:56 +08:00
parent 6b4dc48fbd
commit 1a81f2cccd
26 changed files with 1605 additions and 3 deletions

View File

@@ -0,0 +1,39 @@
# @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 static `provider.capabilities` descriptor, 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/proposed/feature/2026-06-21-subagent-capability-seam.md](../../../docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md).
See `src/types.ts` for the full contracts.

View File

@@ -0,0 +1,34 @@
{
"name": "@deepseek-ai/dsh-subagent",
"description": "Abstract subagent seam (ctx.subagents): named-provider registry for delegating to child agents",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"exports": {
".": {
"types": "./lib/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,191 @@
/**
* The subagent seam (`ctx.subagents`): a named-provider registry plus a
* capability-validating `start` surface. A subagent is an agent delegating
* work to another agent; a {@link SubagentProvider} is one transport for
* running that child (in-process spawn/fork, ACP to another process, and —
* later — A2A, the Codex app-server, the Claude Code Agent SDK).
*
* 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 package is the INTERFACE third of the capability seam. Implementations
* (`@deepseek-ai/dsh-subagent-spawn`, `-fork`, `-acp`) and the model-facing
* consumer (`@deepseek-ai/dsh-tool-subagent`) are separate packages.
*
* Scope (first cut): the consumer collects synchronously — it starts a run and
* awaits {@link SubagentRun.result}. Steering ({@link SubagentRun.sendMessage})
* is part of the contract but intentionally unused; background / poll / spill
* semantics are deferred to a future redesign that unifies long-running-tool
* handling across subagents and bash.
*
* @module @deepseek-ai/dsh-subagent
*/
import { Context, Service } from 'cordis'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { AgentId } from '@deepseek-ai/dsh-agent'
import type {
SubagentCapabilities,
SubagentProvider,
SubagentResult,
SubagentRun,
SubagentStartRequest,
} from './types.ts'
export type {
SubagentCapabilities,
SubagentProvider,
SubagentResult,
SubagentRun,
SubagentStartRequest,
SubagentStopReason,
SubagentStopReasonMap,
} from './types.ts'
declare module 'cordis' {
interface Context {
subagents: SubagentService
}
interface Events {
/**
* A subagent run started — emitted after the provider is resolved and its
* capabilities validated, as the child run begins. Paired with
* {@link Events['subagent/end']}.
* @mode emit
*/
'subagent/start'(info: SubagentRunInfo): void
/**
* A subagent run settled — emitted when {@link SubagentRun.result}
* resolves (any stop reason). Paired with {@link Events['subagent/start']}.
* @mode emit
*/
'subagent/end'(info: SubagentRunEndInfo): void
}
}
/** Identifying detail for a started subagent run (the `subagent/start` payload). */
export interface SubagentRunInfo {
/** The provider that started the run. */
provider: string
/** The child agent/session id. */
id: AgentId
}
/** Outcome detail for a settled subagent run (the `subagent/end` payload). */
export interface SubagentRunEndInfo {
/** The provider that ran it. */
provider: string
/** The child agent/session id. */
id: AgentId
/** The terminal stop reason. */
stopReason: SubagentResult['stopReason']
}
/**
* Typed error for subagent-seam failures. Extends {@link HarnessError}, so the
* `code` string (`DUPLICATE_PROVIDER`, `NO_PROVIDER`, `UNSUPPORTED_CAPABILITY`)
* is shared, machine-routable taxonomy.
*/
export class SubagentError extends HarnessError {
constructor(message: string, code: string, options?: ErrorOptions) {
super(message, code, options)
this.name = 'SubagentError'
}
}
/**
* The `subagents` service: a registry of named {@link SubagentProvider}s and a
* capability-checked {@link start} surface.
*/
export class SubagentService extends Service {
private providers = new Map<string, SubagentProvider>()
constructor(ctx: Context) {
super(ctx, 'subagents')
}
/**
* Register a provider under its `provider.name`. Throws {@link SubagentError}
* (`DUPLICATE_PROVIDER`) if the name is already taken. Effect-scoped: disposed
* with the calling fiber (HMR-safe).
*/
registerProvider(provider: SubagentProvider): () => void {
const dispose = this.ctx.effect(function* (this: SubagentService) {
if (this.providers.has(provider.name)) {
throw new SubagentError(`a subagent provider named "${provider.name}" is already registered`, 'DUPLICATE_PROVIDER')
}
this.providers.set(provider.name, provider)
yield () => {
this.providers.delete(provider.name)
}
}.bind(this), 'subagents.registerProvider()')
// ctx.effect's disposer returns Promise<void>; our disposer API is
// synchronous fire-and-forget — discard the (always-resolved) promise.
return () => void dispose()
}
/** Look up a registered provider by name (`undefined` if absent). */
getProvider(name: string): SubagentProvider | undefined {
return this.providers.get(name)
}
/** The names of all registered providers (insertion order). */
list(): string[] {
return [...this.providers.keys()]
}
/**
* Start a subagent run on the named provider. Resolves the provider (throws
* `NO_PROVIDER` if absent), validates every requested START-TIME capability
* against {@link SubagentProvider.capabilities} (throws `UNSUPPORTED_CAPABILITY`
* for the first unmet one — fail loud, before any child is created), then
* delegates to {@link SubagentProvider.start} and emits `subagent/start` /
* `subagent/end` around the run.
*/
start(name: string, request: SubagentStartRequest): SubagentRun {
const provider = this.providers.get(name)
if (!provider) {
throw new SubagentError(`no subagent provider registered for "${name}"`, 'NO_PROVIDER')
}
this.assertCapabilities(provider, request)
const run = provider.start(request)
this.ctx.emit('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
// as 'error' for the telemetry event without swallowing the rejection
// (the consumer still observes it via `run.result`).
void run.result.then(
(result) => { this.ctx.emit('subagent/end', { provider: name, id: run.id, stopReason: result.stopReason }) },
() => { this.ctx.emit('subagent/end', { provider: name, id: run.id, stopReason: 'error' }) },
)
return run
}
/**
* Reject a request that needs a start-time capability the provider lacks.
* Each optional request field maps to one {@link SubagentCapabilities} flag;
* the first unmet one throws `UNSUPPORTED_CAPABILITY`.
*/
private assertCapabilities(provider: SubagentProvider, request: SubagentStartRequest): void {
const needs: { when: boolean; cap: keyof SubagentCapabilities }[] = [
{ when: request.outputSchema !== undefined, cap: 'outputSchema' },
{ when: request.maxDepth !== undefined, cap: 'depthLimit' },
{ when: request.toolFilter !== undefined, cap: 'toolFilter' },
]
for (const { when, cap } of needs) {
if (when && !provider.capabilities[cap]) {
throw new SubagentError(
`subagent provider "${provider.name}" does not support the "${cap}" capability`,
'UNSUPPORTED_CAPABILITY',
)
}
}
}
}
export default SubagentService

View File

@@ -0,0 +1,172 @@
/**
* Subagent seam vocabulary: the request/result/capability types a
* {@link SubagentProvider} consumes and produces. No runtime code — types
* only, per the package convention.
*
* @module @deepseek-ai/dsh-subagent/types
*/
import type { Agent, AgentId, AgentOptions } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SchemaSpec } from '@deepseek-ai/dsh-tools'
/**
* Which START-TIME features a provider supports. Checked by the service
* BEFORE delegating to {@link SubagentProvider.start}: a request that needs a
* capability the chosen provider lacks is rejected with a typed error rather
* than accepted-then-ignored (the "fail loud, no silent degradation" rule).
*
* Start-time features live here (a static descriptor) because they must be
* checked before a run exists. RUNTIME features (steering, resume) are instead
* modeled as OPTIONAL METHODS on {@link SubagentRun}: the method's presence IS
* the capability, and TS narrowing is the discovery mechanism — a consumer
* cannot call an absent method without narrowing first.
*/
export interface SubagentCapabilities {
/** Honor {@link SubagentStartRequest.outputSchema} (structured final output). */
outputSchema: boolean
/** Enforce {@link SubagentStartRequest.maxDepth} (recursion cap). */
depthLimit: boolean
/** Enforce {@link SubagentStartRequest.toolFilter} (child tool scoping). */
toolFilter: boolean
}
/**
* 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 {@link SubagentCapabilities} against the named provider, then
* passes it to {@link SubagentProvider.start}.
*/
export interface SubagentStartRequest {
/** The task/prompt for the child agent (a user message in the child session). */
prompt: ContentBlock[]
/**
* The spawning ("parent") agent — the one whose tool call started this
* subagent. REQUIRED: in-process backends read `parent.session.header` for
* the working directory, the `parentSession` lineage to stamp on the child,
* and the parent's delegation depth. Out-of-process backends (ACP) ignore it.
*/
parent: Agent
/**
* Cancellation signal from the spawning context (the tool's `exec.signal`).
* A provider that honors it aborts the child when the signal fires; the
* consumer also bridges it to {@link SubagentRun.cancel} explicitly.
*/
signal?: AbortSignal
/** Per-child agent options (model, system prompt). */
agentOptions?: AgentOptions
/**
* Optional structured-output schema. When set AND the provider's
* {@link SubagentCapabilities.outputSchema} is `true`, the child's final
* answer is shaped to this schema and surfaced as {@link SubagentResult.structured}.
* Requesting it against a provider that lacks the capability is rejected at start.
*/
outputSchema?: SchemaSpec
/**
* Optional recursion cap (max delegation depth below this child). Requires
* {@link SubagentCapabilities.depthLimit}; rejected at start otherwise.
*/
maxDepth?: number
/**
* Optional child tool scoping. Requires {@link SubagentCapabilities.toolFilter};
* rejected at start otherwise.
*/
toolFilter?: { allow?: string[]; deny?: string[] }
}
/**
* Why a subagent run ended. Merge-extensible (a backend may add variants);
* consumers branch on the known cases and fall through `default`. The known
* cases mirror the harness turn-end vocabulary so the tool layer can map a
* non-`completed` result to an `isError` tool result.
*/
export interface SubagentStopReasonMap {
/** The child finished its turn normally. */
completed: 'completed'
/** The run was cancelled (parent signal, explicit `cancel()`, or peer cancel). */
aborted: 'aborted'
/** The child failed (model error, transport error). */
error: 'error'
/** The child hit its token ceiling before finishing. */
'max-tokens': 'max-tokens'
/** The child declined the task. */
refusal: 'refusal'
}
export type SubagentStopReason = SubagentStopReasonMap[keyof SubagentStopReasonMap]
/**
* The terminal outcome of a subagent run, resolved by {@link SubagentRun.result}.
*/
export interface SubagentResult {
/** The child's final assistant output (the last assistant message's content). */
output: ContentBlock[]
/**
* The structured result, present IFF the request carried an `outputSchema`
* AND the provider honored it. Shape is validated against the request schema
* by the provider; `unknown` here because the seam is schema-agnostic.
*/
structured?: unknown
/** Why the run ended. A non-`completed` reason means `output` may be partial. */
stopReason: SubagentStopReason
}
/**
* A live subagent run: a handle the consumer holds while a child executes.
* Returned by {@link SubagentProvider.start} (via the service). The consumer
* awaits {@link result}, may {@link cancel} mid-flight, and MUST {@link dispose}
* on every path to reach child quiescence (no leaked idle child / session).
*
* {@link sendMessage} and {@link resume} are OPTIONAL: a provider that supports
* the runtime capability defines the method; one that doesn't omits it. The
* presence of the method IS the capability — narrow before calling.
*/
export interface SubagentRun {
/** The child agent's id (also its session id token, for correlation). */
readonly id: AgentId
/**
* Resolves with the child's terminal {@link SubagentResult} when the run
* settles. Does NOT reject on a child-level failure — a model/transport
* failure resolves with `stopReason: 'error'` so the consumer maps it to an
* `isError` tool result. Rejects only on an infrastructure fault the seam
* cannot represent as a stop reason.
*/
readonly result: Promise<SubagentResult>
/** Request cancellation of the in-flight run; {@link result} settles `aborted`. */
cancel(reason?: string): void
/**
* Reach child quiescence and release the run's resources (in-process: dispose
* the owned agent handle and remove its session; ACP: kill the subprocess).
* Idempotent; awaits the child actually stopping, not merely requesting it.
*/
dispose(): Promise<void>
/**
* OPTIONAL (steering capability): send additional content to the running
* child between steps. Present only on providers that support live steering.
*/
sendMessage?(content: ContentBlock[]): void
/**
* OPTIONAL (resume capability): send a follow-up task to a settled child,
* continuing its session, and return a fresh run for the continuation.
*/
resume?(content: ContentBlock[]): SubagentRun
}
/**
* A subagent backend: one transport for running a child agent (in-process
* spawn/fork, ACP to another process, …). Implementations register under a
* unique name via {@link SubagentService.registerProvider}; multiple providers
* coexist in one context (unlike the single-implementation bash seam).
*/
export interface SubagentProvider {
/** Unique registry name (e.g. `spawn`, `fork`, `acp`). */
readonly name: string
/** The start-time features this provider supports (see {@link SubagentCapabilities}). */
readonly capabilities: SubagentCapabilities
/**
* Start a child run. The service has already validated that every requested
* start-time capability is supported, so an implementation may assume e.g.
* `request.maxDepth` is honorable when present.
*/
start(request: SubagentStartRequest): SubagentRun
}

View File

@@ -0,0 +1,182 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import SubagentService, {
SubagentError,
type SubagentCapabilities,
type SubagentProvider,
type SubagentResult,
type SubagentRun,
type SubagentStartRequest,
} from '@deepseek-ai/dsh-subagent'
/** A minimal parent Agent stand-in — the service only reads `parent.id`. */
function fakeParent(id = 'parent-1'): Agent {
return { id: AgentId(id) } as unknown as Agent
}
const ALL_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true }
const NO_CAPS: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false }
/** A scripted provider whose run settles immediately with a fixed result. */
class StubProvider implements SubagentProvider {
startCount = 0
constructor(
readonly name: string,
readonly capabilities: SubagentCapabilities = ALL_CAPS,
private readonly result: SubagentResult = { output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' },
) {}
start(request: SubagentStartRequest): SubagentRun {
this.startCount++
return {
id: AgentId(`child:${this.name}:${request.parent.id}`),
result: Promise.resolve(this.result),
cancel() {},
async dispose() {},
}
}
}
function baseRequest(overrides: Partial<SubagentStartRequest> = {}): SubagentStartRequest {
return { prompt: [{ type: 'text', text: 'do a thing' }], parent: fakeParent(), ...overrides }
}
describe('SubagentService', () => {
it('registers a provider and starts a run on it by name', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
const provider = new StubProvider('alpha')
ctx.subagents.registerProvider(provider)
expect(ctx.subagents.list()).toEqual(['alpha'])
expect(ctx.subagents.getProvider('alpha')).toBe(provider)
const run = ctx.subagents.start('alpha', baseRequest())
expect(provider.startCount).toBe(1)
await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
})
it('lets multiple providers coexist (the defining requirement)', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider(new StubProvider('spawn'))
ctx.subagents.registerProvider(new StubProvider('acp'))
expect(ctx.subagents.list()).toEqual(['spawn', 'acp'])
expect(ctx.subagents.getProvider('spawn')).toBeDefined()
expect(ctx.subagents.getProvider('acp')).toBeDefined()
})
it('throws NO_PROVIDER when starting on an unregistered name', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
try {
ctx.subagents.start('missing', baseRequest())
expect.fail('expected NO_PROVIDER')
} catch (error: unknown) {
expect(error).toBeInstanceOf(SubagentError)
expect((error as SubagentError).code).toBe('NO_PROVIDER')
}
})
it('rejects duplicate provider names with DUPLICATE_PROVIDER', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider(new StubProvider('dup'))
try {
ctx.subagents.registerProvider(new StubProvider('dup'))
expect.fail('expected DUPLICATE_PROVIDER')
} catch (error: unknown) {
expect(error).toBeInstanceOf(SubagentError)
expect((error as SubagentError).code).toBe('DUPLICATE_PROVIDER')
}
})
it('unregisters a provider when its owning fiber is disposed (HMR safety)', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.subagents.registerProvider(new StubProvider('scoped'))
}, { inject: ['subagents'] }))
expect(ctx.subagents.list()).toEqual(['scoped'])
await fiber.dispose()
expect(ctx.subagents.list()).toEqual([])
})
it('re-registers a name after its prior registration is disposed (not wedged)', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
const dispose = ctx.subagents.registerProvider(new StubProvider('reuse'))
expect(ctx.subagents.list()).toEqual(['reuse'])
dispose()
expect(ctx.subagents.list()).toEqual([])
const disposeAgain = ctx.subagents.registerProvider(new StubProvider('reuse'))
expect(ctx.subagents.list()).toEqual(['reuse'])
disposeAgain()
expect(ctx.subagents.list()).toEqual([])
})
describe('start-time capability validation (fail loud, before any child)', () => {
it.each([
{ field: 'outputSchema', request: baseRequest({ outputSchema: { x: { type: 'string' } } }) },
{ field: 'maxDepth', request: baseRequest({ maxDepth: 2 }) },
{ field: 'toolFilter', request: baseRequest({ toolFilter: { deny: ['bash'] } }) },
])('rejects $field against a provider that lacks the capability — before start() runs', ({ request }) => {
const ctx = new Context()
return ctx.plugin(SubagentService).then(() => {
const provider = new StubProvider('weak', NO_CAPS)
ctx.subagents.registerProvider(provider)
try {
ctx.subagents.start('weak', request)
expect.fail('expected UNSUPPORTED_CAPABILITY')
} catch (error: unknown) {
expect(error).toBeInstanceOf(SubagentError)
expect((error as SubagentError).code).toBe('UNSUPPORTED_CAPABILITY')
}
// The child was never started — the check is pre-spawn.
expect(provider.startCount).toBe(0)
})
})
it('allows a capability request when the provider supports it', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
const provider = new StubProvider('strong', ALL_CAPS)
ctx.subagents.registerProvider(provider)
ctx.subagents.start('strong', baseRequest({ outputSchema: { x: { type: 'string' } }, maxDepth: 1 }))
expect(provider.startCount).toBe(1)
})
})
it('emits subagent/start then subagent/end around a run', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider(new StubProvider('events'))
const started = vi.fn()
const ended = vi.fn()
ctx.on('subagent/start', started)
ctx.on('subagent/end', ended)
const run = ctx.subagents.start('events', baseRequest())
expect(started).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id }))
await run.result
// `subagent/end` fires from a `.then` on the result — let the microtask run.
await Promise.resolve()
expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id, stopReason: 'completed' }))
})
it('SubagentError extends the shared HarnessError base', () => {
const err = new SubagentError('boom', 'NO_PROVIDER')
expect(err).toBeInstanceOf(HarnessError)
expect(err.name).toBe('SubagentError')
expect(err.code).toBe('NO_PROVIDER')
})
})

View File

@@ -0,0 +1,27 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../core/agent"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/tools"
}
]
}