refactor(subagent): unify async readiness and cancellation
This commit is contained in:
@@ -1,44 +1,61 @@
|
||||
# @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.
|
||||
The subagent seam lets one agent delegate work to a child through a named provider. Callers use one service API (`ctx.subagents`); providers decide whether the child runs in this process, in another process, or through a future transport.
|
||||
|
||||
This package is the interface third of the capability seam, split so each concern evolves (and swaps) independently:
|
||||
## Package roles
|
||||
|
||||
The family separates the stable interface from implementations and model-facing tools:
|
||||
|
||||
| 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` |
|
||||
| `@deepseek-ai/dsh-subagent` | Provider registry, request/result types, and lifecycle events. |
|
||||
| `@deepseek-ai/dsh-subagent-spawn` | Fresh in-process child. |
|
||||
| `@deepseek-ai/dsh-subagent-fork` | In-process child seeded with completed parent turns. |
|
||||
| `@deepseek-ai/dsh-subagent-acp` | Fresh out-of-process ACP child. |
|
||||
| `@deepseek-ai/dsh-tool-subagent` | Model-facing tool over one configured provider. |
|
||||
|
||||
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.
|
||||
Multiple providers may coexist under different names. This lets a deployment expose, for example, a cheap in-process child and an isolated ACP child without changing the service contract.
|
||||
|
||||
## Service API (`ctx.subagents`)
|
||||
## Service API
|
||||
|
||||
| Member | Semantics |
|
||||
`SubagentService` has four main operations:
|
||||
|
||||
| Member | Meaning |
|
||||
|---|---|
|
||||
| `registerProvider(provider)` | Read and validate the name, capability object and four boolean flags, `inheritsParentContext`, and `start` callback exactly once, then register a frozen acceptance snapshot under the accepted name. Malformed fixed fields fail loud before registration; later caller mutation cannot change registry behavior or HMR cleanup, while `start` stays bound to the original provider receiver. Throws `SubagentError('DUPLICATE_PROVIDER')` on a name clash. Effect-scoped (HMR-safe); returns the disposer. |
|
||||
| `assertSubagentMaxDepth(value)` | Shared runtime boundary for recursion caps. Accepts absence or a non-negative safe integer; rejects fractions, non-finite numbers, negative values, negative zero, and unsafe integers. The service, direct in-process driver, and model-facing config adapter all use it. |
|
||||
| `getProvider(name)` | Look up the frozen registry snapshot (`undefined` if absent). |
|
||||
| `list()` | Registered provider names (insertion order). |
|
||||
| `start(name, request)` | Resolve the provider (`NO_PROVIDER` if absent), read every caller field once into one acceptance snapshot, validate every requested START-TIME capability and scalar value before any child is created, and materialize prompt/schema/options/filter data through a single-pass lossless-JSON snapshot before delegating to `provider.start`. Acquire the provider run's disposer before reading the rest of its handle, then return a frozen service-owned wrapper whose fields are captured once, whose methods remain bound to the provider handle, and whose `result` is one detached, deeply frozen normalization shared by the caller and telemetry. The wrapper claims its shared disposal promise before invoking raw provider code, so synchronous reentry and ordinary repeats join one provider call; a raw disposer that directly returns that same reentrant wrapper promise is rejected as a cyclic provider contract instead of hanging forever. Malformed handle access/binding starts rollback before the synchronous fault escapes; malformed terminal data rejects only after rollback reaches quiescence. Emit `subagent/start` only after `run.started` fulfills and the paired `subagent/end` after that started run settles; a pre-publication readiness rejection emits neither. |
|
||||
| `registerProvider(provider)` | Register one trusted same-process implementation by name. Registration is effect-scoped; removing it prevents new starts but does not revoke runs already returned to callers. Duplicate names fail loud. |
|
||||
| `getProvider(name)` | Return the provider, or `undefined` when absent. |
|
||||
| `list()` | Return provider names in insertion order. |
|
||||
| `start(name, request)` | Validate requested capabilities and semantic values, then await the provider until a real child is ready. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every partial startup resource. |
|
||||
|
||||
## Capabilities: two kinds, discovered two ways
|
||||
`SubagentStartRequest.signal` is required and is the canonical cancellation channel. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the live child. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona.
|
||||
|
||||
- **Start-time features** (`outputSchema`, `depthLimit`, `toolFilter/persona`) 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.
|
||||
Same-process requests, descriptors, results, and event payloads are trusted typed values borrowed as immutable. The service does not clone or freeze them; serialization and hostile-input validation belong at actual process, worker, persistence, and model boundaries.
|
||||
|
||||
Beside `capabilities` sits one DESCRIPTIVE fact: `provider.inheritsParentContext` — whether a child sees the parent conversation (`fork`: true — seeded with the completed-turn prefix; `spawn`/`acp`: false). “Context” here means conversation history only; it says nothing about tool registrations, injected services, or authority inheritance. The service validates that the descriptor is a boolean but does not interpret or enforce its meaning; the model-facing consumer (`dsh-tool-subagent`) derives truthful tool wording from it.
|
||||
## Capabilities
|
||||
|
||||
## Run lifecycle
|
||||
Start-time features are advertised in `provider.capabilities` because the service must reject an unsupported request before child creation:
|
||||
|
||||
`provider.start(request)` returns a provider-owned `SubagentRun`; `SubagentService.start` captures that handle once and returns a frozen service-owned wrapper with `started` (the publication/readiness promise), a normalized `result` (the terminal outcome), bound `cancel()` and `dispose()`, and bound optional runtime methods. `started` resolves only after the provider has established a real child and rejects if the attempt fails or is cancelled first. `result` resolves with one detached, deeply frozen `SubagentResult` (`output`, optional `structured`, `stopReason`) that the service and caller share — it does **not** reject on a child-level failure (a model/transport failure resolves with `stopReason: 'error'`), but malformed provider data rejects as an infrastructure contract fault. The consumer maps a non-`completed` reason to an `isError` tool result and MUST `dispose()` on every path (success, error, abort) to reach child quiescence and avoid leaking an idle child / session.
|
||||
- `outputSchema` — enforce a structured final result.
|
||||
- `depthLimit` — enforce `maxDepth`.
|
||||
- `toolFilter` — apply the requested child tool restriction.
|
||||
- `persona` — apply a per-child persona.
|
||||
|
||||
The service also announces provider lifecycle: `subagent/provider-added` (the frozen registry snapshot) fires after a registration and `subagent/provider-removed` (the accepted name) after an unregistration, so a consumer deriving state from a named provider (the model-facing tool wording) mirrors registry membership instead of assuming load order — the cordis Loader starts sibling plugins concurrently, so "listed earlier" does not mean "registered earlier". Run lifecycle is gated by provider readiness: the service captures the provider handle's public fields once, `subagent/start` (payload `SubagentRunInfo`) fires only after the accepted `started` promise fulfills, and `subagent/end` (payload `SubagentRunEndInfo`) uses the same accepted id and normalized result; readiness rejection emits neither. For spawn/fork, the start listener can resolve the published child via `ctx.agents.get(info.id)`; a remote provider need not have a local registry entry. Both events are **observe-only** plain emits whose service-owned payloads are deeply frozen before per-listener dispatch. A synchronous listener throw or returned-promise rejection is logged per listener; later listeners still run synchronously, and async listeners remain concurrent fire-and-forget. The service observes the normalized `result` immediately even while readiness is pending and buffers that end payload until start has fired; a malformed provider result rejects the returned result promise and becomes contained `error` telemetry, a rejection cannot become an unhandled detached promise, start always precedes end, and one listener cannot corrupt either the caller or later listeners. `subagent/end` carries the same frozen output as `lastAssistantMessage` on a valid settle path and omits it on infrastructure or result-contract failure. Any run-affecting decision is out of scope for this observe-only surface.
|
||||
Runtime features are optional methods on `SubagentRun`: `sendMessage?` steers a live child, while `resume?` asynchronously creates a continuation run. Method presence is the capability check.
|
||||
|
||||
## Scope (first cut)
|
||||
`inheritsParentContext` is descriptive rather than enforceable. It says only whether the child sees completed parent conversation history (`fork` does; `spawn` and ACP do not), not whether it inherits tools, services, or authority.
|
||||
|
||||
The consumer collects **synchronously**: it starts a run and awaits `result`. Steering (`sendMessage`) is part of the contract but intentionally unused. Background, poll, and spill semantics are outside this seam; long-running-tool handling is shared work across subagents and bash. See the RFC: [docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md).
|
||||
## Ownership and lifecycle
|
||||
|
||||
See `src/types.ts` for the full contracts.
|
||||
`provider.start(request): Promise<SubagentRun>` is the ownership-transfer boundary. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce partial resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path.
|
||||
|
||||
`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for the child resources to quiesce.
|
||||
|
||||
The service emits `subagent/start` only after `start()` has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. In-process start observers can resolve the published child through `ctx.agents.get(info.id)`; remote providers need not publish a local agent.
|
||||
|
||||
Run events are scoped to the delegating parent. Every listener is independently contained: a synchronous throw or rejected returned promise is logged without starving peer listeners or changing the run.
|
||||
|
||||
Provider additions and removals also emit `subagent/provider-added` and `subagent/provider-removed`. Consumers such as the model-facing tool use those events because Cordis may load sibling plugins concurrently; configuration order does not prove registration order.
|
||||
|
||||
## Collection model
|
||||
|
||||
The current model-facing tool collects synchronously: it awaits the child result and disposes the run before returning. Background collection and polling remain outside this seam. See the [capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md) and `src/types.ts` for the complete contracts.
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
@@ -33,7 +32,6 @@
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
|
||||
@@ -1,44 +1,23 @@
|
||||
/**
|
||||
* 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).
|
||||
* capability-validating asynchronous start surface. Providers establish a
|
||||
* child before returning its run, so fulfillment is the single publication and
|
||||
* ownership-transfer boundary.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
* before settling, and a `resume` capability on the in-process provider — part
|
||||
* of the deferred background/steering redesign, NOT this observe-only cut.
|
||||
* Same-process providers are trusted typed collaborators. Requests, provider
|
||||
* descriptors, results, and lifecycle payloads are borrowed immutable values;
|
||||
* serialization and hostile-input validation belong at real process, worker,
|
||||
* persistence, and model boundaries.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import { assertSupportedOutputSchema, OutputSchemaError } from '@deepseek-ai/dsh-tools'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import { deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import type {
|
||||
SubagentCapabilities,
|
||||
@@ -60,10 +39,6 @@ export type {
|
||||
|
||||
/**
|
||||
* Reject a recursion cap that cannot represent an exact delegation depth.
|
||||
* Undefined means the caller did not request a cap and is accepted. The
|
||||
* service, direct in-process driver, and model-facing config adapter share this
|
||||
* boundary so no entry path can turn a fractional or non-finite value into an
|
||||
* ineffective limit.
|
||||
* @param maxDepth - the optional runtime value to validate.
|
||||
*/
|
||||
export function assertSubagentMaxDepth(maxDepth: unknown): void {
|
||||
@@ -84,88 +59,56 @@ declare module 'cordis' {
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* A provider became resolvable in the {@link SubagentService} registry.
|
||||
* Consumers that derive state from a named provider (e.g. the model-facing
|
||||
* tool wording in `dsh-tool-subagent`) react HERE instead of assuming load
|
||||
* order — the cordis Loader starts sibling plugins concurrently, so
|
||||
* "listed earlier in cordis.yml" does not mean "registered earlier".
|
||||
* @param provider - the registry's frozen acceptance snapshot of the provider.
|
||||
* A provider became resolvable in the registry.
|
||||
* @param provider - the registered provider.
|
||||
* @mode emit
|
||||
*/
|
||||
'subagent/provider-added'(provider: SubagentProvider): void
|
||||
/**
|
||||
* A provider left the registry (its plugin's fiber was disposed — an
|
||||
* unload or an HMR reload). Consumers holding provider-derived state drop
|
||||
* it here; a reload re-fires `subagent/provider-added` with the fresh
|
||||
* provider. Delivered with per-listener containment: a throwing
|
||||
* subscriber is logged, never starves later subscribers, and never
|
||||
* disrupts the provider's teardown.
|
||||
* @param name - the registry name that no longer resolves.
|
||||
* A provider left the registry. Accepted runs remain holder-owned.
|
||||
* @param name - the provider name that no longer resolves.
|
||||
* @mode emit
|
||||
*/
|
||||
'subagent/provider-removed'(name: string): void
|
||||
/**
|
||||
* A subagent run started — emitted only after {@link SubagentRun.started}
|
||||
* fulfills, when the provider has established a live child. For an
|
||||
* in-process provider, `ctx.agents.get(info.id)` is therefore guaranteed to
|
||||
* resolve during this notification. A readiness rejection emits neither
|
||||
* lifecycle event; every emitted start is paired with
|
||||
* {@link Events['subagent/end']}.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed
|
||||
* by the DELEGATING PARENT — a listener registered through the parent's
|
||||
* `agent.ctx` observes only its own delegations; a plain plugin listener
|
||||
* observes every run.
|
||||
* @param info - which provider started which child agent.
|
||||
* A provider established a ready child. For in-process providers,
|
||||
* `ctx.agents.get(info.id)` resolves during this notification.
|
||||
* Scope-filtered by the delegating parent and paired with `subagent/end`.
|
||||
* @param info - the provider and ready child identity.
|
||||
* @mode emit
|
||||
*/
|
||||
'subagent/start'(this: Scoped<SubagentService>, info: SubagentRunInfo): void
|
||||
/**
|
||||
* A started subagent run settled — emitted when {@link SubagentRun.result}
|
||||
* resolves (any stop reason) or rejects (reported as `error`). Paired with
|
||||
* {@link Events['subagent/start']}; a run whose readiness rejected emits
|
||||
* neither event.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed
|
||||
* by the DELEGATING PARENT — a listener registered through the parent's
|
||||
* `agent.ctx` observes only its own delegations; a plain plugin listener
|
||||
* observes every run.
|
||||
* @param info - the run identity plus stop reason and final output.
|
||||
* A ready child settled. Scope-filtered by the delegating parent and
|
||||
* paired with `subagent/start`.
|
||||
* @param info - the run identity and terminal outcome.
|
||||
* @mode emit
|
||||
*/
|
||||
'subagent/end'(this: Scoped<SubagentService>, info: SubagentRunEndInfo): void
|
||||
}
|
||||
}
|
||||
|
||||
/** Deep-frozen, observe-only identifying detail for a started subagent run. */
|
||||
/** Observe-only identifying detail for a ready subagent run. */
|
||||
export interface SubagentRunInfo {
|
||||
/** The provider that started the run. */
|
||||
provider: string
|
||||
/** The provider that established the run. */
|
||||
readonly provider: string
|
||||
/** The child agent's id. */
|
||||
id: AgentId
|
||||
readonly id: AgentId
|
||||
}
|
||||
|
||||
/** Deep-frozen, observe-only outcome detail for a settled subagent run. */
|
||||
/** Observe-only outcome detail for a settled subagent run. */
|
||||
export interface SubagentRunEndInfo {
|
||||
/** The provider that ran it. */
|
||||
provider: string
|
||||
readonly provider: string
|
||||
/** The child agent's id. */
|
||||
id: AgentId
|
||||
readonly id: AgentId
|
||||
/** The terminal stop reason. */
|
||||
stopReason: SubagentResult['stopReason']
|
||||
/**
|
||||
* The child's final assistant output ({@link SubagentResult.output}), carried
|
||||
* onto the end event so an observer sees WHAT the subagent produced without
|
||||
* holding the run. Absent when the run rejected at the infrastructure level
|
||||
* (no {@link SubagentResult} was produced — the seam only knows `stopReason:
|
||||
* 'error'`).
|
||||
*/
|
||||
lastAssistantMessage?: ContentBlock[]
|
||||
readonly stopReason: SubagentResult['stopReason']
|
||||
/** The child's final assistant output, absent on infrastructure rejection. */
|
||||
readonly lastAssistantMessage?: ContentBlock[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed error for subagent-seam failures. Extends {@link HarnessError}, so the
|
||||
* `code` string (`DUPLICATE_PROVIDER`, `NO_PROVIDER`, `UNSUPPORTED_CAPABILITY`)
|
||||
* is shared, machine-routable taxonomy.
|
||||
*/
|
||||
/** Typed error for provider lookup, registration, and capability failures. */
|
||||
export class SubagentError extends HarnessError {
|
||||
constructor(message: string, code: string, options?: ErrorOptions) {
|
||||
super(message, code, options)
|
||||
@@ -173,10 +116,7 @@ export class SubagentError extends HarnessError {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `subagents` service: a registry of named {@link SubagentProvider}s and a
|
||||
* capability-checked {@link start} surface.
|
||||
*/
|
||||
/** Named provider registry and capability-checked start surface. */
|
||||
export class SubagentService extends Service {
|
||||
private providers = new Map<string, SubagentProvider>()
|
||||
|
||||
@@ -185,451 +125,88 @@ export class SubagentService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a provider under its `provider.name`. Throws {@link SubagentError}
|
||||
* (`DUPLICATE_PROVIDER`) if the name is already taken. The registry snapshots
|
||||
* the name, static descriptors, and `start` callback identity at acceptance;
|
||||
* every fixed field and capability flag is read once and validated before
|
||||
* registration, so malformed provider objects fail loud without entering the
|
||||
* registry. Later caller mutation cannot change lookup, capability validation,
|
||||
* consumer wording, dispatch, or HMR cleanup. The callback remains bound to
|
||||
* the original provider object, so provider-owned mutable state stays live.
|
||||
* Effect-scoped: disposed with the calling fiber (HMR-safe). Emits
|
||||
* `subagent/provider-added` after the registration and
|
||||
* `subagent/provider-removed` on unregistration, so consumers can mirror
|
||||
* provider lifecycle instead of assuming load order.
|
||||
* @param provider - the provider; its `name` is the registry key.
|
||||
* @returns the disposer that unregisters the provider. The exact
|
||||
* Cordis effect disposer (single-shot): composite (generator) effects may
|
||||
* yield it directly — exact identity nests the teardown in order.
|
||||
* Register a provider under its name. Registration is effect-scoped and HMR
|
||||
* safe; removing a provider blocks new starts but does not revoke runs that
|
||||
* were already returned to their holders.
|
||||
* @param provider - the trusted provider implementation.
|
||||
* @returns the exact Cordis effect disposer.
|
||||
*/
|
||||
registerProvider(provider: SubagentProvider): () => Promise<void> | void {
|
||||
// Snapshot the accepted registration contract before entering the effect.
|
||||
// Cleanup must never re-read caller-owned `provider.name`: an HMR host may
|
||||
// mutate or reuse the provider object before its old fiber unloads. Binding
|
||||
// preserves the provider method's receiver while making replacement of the
|
||||
// public callback field after registration inert.
|
||||
const name: unknown = provider.name
|
||||
const inputCapabilities: unknown = provider.capabilities
|
||||
const inheritsParentContext: unknown = provider.inheritsParentContext
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const inputStart: unknown = provider.start
|
||||
if (typeof name !== 'string') {
|
||||
throw new TypeError('subagent provider name must be a string')
|
||||
}
|
||||
if (inputCapabilities === null || typeof inputCapabilities !== 'object' || Array.isArray(inputCapabilities)) {
|
||||
throw new TypeError(`subagent provider "${name}" capabilities must be an object`)
|
||||
}
|
||||
const inputCapabilityFields = inputCapabilities as Record<keyof SubagentCapabilities, unknown>
|
||||
const outputSchema = inputCapabilityFields.outputSchema
|
||||
const depthLimit = inputCapabilityFields.depthLimit
|
||||
const toolFilter = inputCapabilityFields.toolFilter
|
||||
const persona = inputCapabilityFields.persona
|
||||
for (const [capability, value] of [
|
||||
['outputSchema', outputSchema],
|
||||
['depthLimit', depthLimit],
|
||||
['toolFilter', toolFilter],
|
||||
['persona', persona],
|
||||
] as const) {
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new TypeError(`subagent provider "${name}" capability "${capability}" must be a boolean`)
|
||||
const name = provider.name
|
||||
return this.ctx.effect(function* (this: SubagentService) {
|
||||
if (this.providers.has(name)) {
|
||||
throw new SubagentError(`a subagent provider named "${name}" is already registered`, 'DUPLICATE_PROVIDER')
|
||||
}
|
||||
}
|
||||
if (typeof inheritsParentContext !== 'boolean') {
|
||||
throw new TypeError(`subagent provider "${name}" inheritsParentContext must be a boolean`)
|
||||
}
|
||||
if (typeof inputStart !== 'function') {
|
||||
throw new TypeError(`subagent provider "${name}" start must be a function`)
|
||||
}
|
||||
const capabilities: SubagentCapabilities = Object.freeze({
|
||||
outputSchema: outputSchema as boolean,
|
||||
depthLimit: depthLimit as boolean,
|
||||
toolFilter: toolFilter as boolean,
|
||||
persona: persona as boolean,
|
||||
})
|
||||
const snapshot: SubagentProvider = Object.freeze({
|
||||
name,
|
||||
capabilities,
|
||||
inheritsParentContext,
|
||||
start: Function.prototype.bind.call(inputStart, provider) as SubagentProvider['start'],
|
||||
})
|
||||
const dispose = this.ctx.effect(function* (this: SubagentService) {
|
||||
if (this.providers.has(snapshot.name)) {
|
||||
throw new SubagentError(`a subagent provider named "${snapshot.name}" is already registered`, 'DUPLICATE_PROVIDER')
|
||||
}
|
||||
this.providers.set(snapshot.name, snapshot)
|
||||
// Yield the rollback BEFORE emitting `subagent/provider-added`: a
|
||||
// throwing added-listener then unregisters the provider (and announces
|
||||
// the removal) instead of leaking it into the registry. The removal
|
||||
// announcement itself is contained PER LISTENER ({@link emitLifecycle}):
|
||||
// it runs inside this disposer, where a propagating subscriber would
|
||||
// disrupt the backend fiber's teardown and starve later mirrors.
|
||||
this.providers.set(name, provider)
|
||||
yield () => {
|
||||
this.providers.delete(snapshot.name)
|
||||
this.emitLifecycle('subagent/provider-removed', snapshot.name)
|
||||
this.providers.delete(name)
|
||||
this.emitLifecycle('subagent/provider-removed', name)
|
||||
}
|
||||
this.ctx.emit('subagent/provider-added', snapshot)
|
||||
// A throwing added-listener unwinds the yielded rollback, matching the
|
||||
// repository's fail-loud registration semantics.
|
||||
this.ctx.emit('subagent/provider-added', provider)
|
||||
}.bind(this), 'subagents.registerProvider()')
|
||||
// The EXACT cordis effect disposer, not a wrapper: a composite (generator)
|
||||
// effect that owns a teardown ORDER must be able to yield THIS function —
|
||||
// cordis nests a disposer out of the fiber's concurrent sibling list by
|
||||
// exact function identity, so a wrapper would silently break the nesting
|
||||
// (the agents.register() lesson). Fire-and-forget callers may still
|
||||
// discard the (always-resolved) promise.
|
||||
return dispose
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the registry's frozen provider snapshot by its accepted name
|
||||
* (`undefined` if absent).
|
||||
* @param name - the provider name accepted at registration.
|
||||
* @returns the frozen acceptance snapshot, or undefined when the name is unknown.
|
||||
* Look up a provider by name.
|
||||
* @param name - the provider name.
|
||||
* @returns the provider, or undefined when absent.
|
||||
*/
|
||||
getProvider(name: string): SubagentProvider | undefined {
|
||||
return this.providers.get(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* The names of all registered providers (insertion order).
|
||||
* @returns the registered provider names.
|
||||
* List registered provider names in insertion order.
|
||||
* @returns the registered names.
|
||||
*/
|
||||
list(): string[] {
|
||||
return [...this.providers.keys()]
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a subagent run on the named provider. Resolves the provider (throws
|
||||
* `NO_PROVIDER` if absent), reads the caller request once into a coherent
|
||||
* acceptance snapshot, 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
|
||||
* validates the request's scalar values, materializes model-bound data in one
|
||||
* lossless-JSON traversal, and delegates the detached request to
|
||||
* {@link SubagentProvider.start}. The returned handle is a service-owned,
|
||||
* frozen wrapper: provider fields are captured once, methods stay bound to the
|
||||
* provider handle, and `result` resolves to one detached, deeply frozen value
|
||||
* shared by the caller and lifecycle telemetry. Once a provider returns a
|
||||
* callable disposer, malformed handle access/binding starts rollback before
|
||||
* the synchronous fault escapes; malformed terminal data rejects only after
|
||||
* that same memoized disposal reaches quiescence. Emits `subagent/start` /
|
||||
* `subagent/end` only after the run's readiness boundary fulfills. A provider
|
||||
* that fails before establishing a child emits neither event.
|
||||
* @param name - the provider to run on.
|
||||
* @param request - the child's prompt, capabilities, and options.
|
||||
* @returns the live run (its `result` resolves when the child settles).
|
||||
* Establish a ready child on the named provider. Capability and semantic
|
||||
* checks run before delegation. Provider ownership lasts until its promise
|
||||
* fulfills; a rejection therefore has no run for the caller to dispose and
|
||||
* emits no run lifecycle events.
|
||||
* @param name - the provider to use.
|
||||
* @param request - child prompt, parent, signal, and optional capabilities.
|
||||
* @returns the ready holder-owned run.
|
||||
*/
|
||||
start(name: string, request: SubagentStartRequest): SubagentRun {
|
||||
async start(name: string, request: SubagentStartRequest): Promise<SubagentRun> {
|
||||
const provider = this.providers.get(name)
|
||||
if (!provider) {
|
||||
if (provider === undefined) {
|
||||
throw new SubagentError(`no subagent provider registered for "${name}"`, 'NO_PROVIDER')
|
||||
}
|
||||
// Read every top-level field exactly once before capability checks or
|
||||
// detachment. A stateful accessor must not look absent to validation and then
|
||||
// appear in the provider request (or vice versa).
|
||||
const input = this.snapshotStartRequest(request)
|
||||
const parent = input.parent
|
||||
this.assertCapabilities(provider, input)
|
||||
assertSubagentMaxDepth(input.maxDepth)
|
||||
if (input.persona !== undefined && typeof input.persona !== 'string') {
|
||||
throw new TypeError('subagent persona must be a string')
|
||||
}
|
||||
// Model/session-bound values are validated and detached in a single
|
||||
// recursive pass. A check followed by structuredClone would reread getters
|
||||
// and could erase an exotic prototype returned only to the clone.
|
||||
const prompt = snapshotJsonValue(input.prompt)
|
||||
if (prompt === undefined) {
|
||||
throw new TypeError('subagent prompt must be losslessly JSON-serializable')
|
||||
}
|
||||
const outputSchema = input.outputSchema === undefined
|
||||
? undefined
|
||||
: snapshotJsonValue(input.outputSchema)
|
||||
if (input.outputSchema !== undefined && outputSchema === undefined) {
|
||||
throw new OutputSchemaError(['schema annotation must be JSON data; the complete schema must be losslessly JSON-serializable'])
|
||||
}
|
||||
if (outputSchema !== undefined) assertSupportedOutputSchema(outputSchema)
|
||||
const agentOptions = input.agentOptions === undefined
|
||||
? undefined
|
||||
: snapshotJsonValue(input.agentOptions)
|
||||
if (input.agentOptions !== undefined && agentOptions === undefined) {
|
||||
throw new TypeError('subagent agent options must be losslessly JSON-serializable')
|
||||
}
|
||||
const toolFilter = input.toolFilter === undefined
|
||||
? undefined
|
||||
: snapshotJsonValue(input.toolFilter)
|
||||
if (input.toolFilter !== undefined && toolFilter === undefined) {
|
||||
throw new TypeError('subagent tool filter must be losslessly JSON-serializable')
|
||||
}
|
||||
this.assertCapabilities(provider, request)
|
||||
assertSubagentMaxDepth(request.maxDepth)
|
||||
if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema)
|
||||
|
||||
// Detach every data field before crossing into a provider. Parent/signal
|
||||
// are live identity capabilities and stay exact; the mutable request record
|
||||
// and its arrays/objects are never retained, so every backend (including an
|
||||
// async out-of-process one) observes the request accepted at start.
|
||||
const accepted: SubagentStartRequest = {
|
||||
prompt,
|
||||
parent,
|
||||
...input.signal !== undefined ? { signal: input.signal } : {},
|
||||
...agentOptions !== undefined ? { agentOptions } : {},
|
||||
...outputSchema !== undefined ? { outputSchema } : {},
|
||||
...input.maxDepth !== undefined ? { maxDepth: input.maxDepth } : {},
|
||||
...toolFilter !== undefined ? { toolFilter } : {},
|
||||
...input.persona !== undefined ? { persona: input.persona } : {},
|
||||
}
|
||||
const providerRun: unknown = provider.start(accepted)
|
||||
if (providerRun === null || (typeof providerRun !== 'object' && typeof providerRun !== 'function')) {
|
||||
throw new TypeError(`subagent provider "${name}" start must return a SubagentRun object`)
|
||||
}
|
||||
const acceptedRun = providerRun as SubagentRun
|
||||
// Acquire the one rollback capability BEFORE touching any other provider-run
|
||||
// field. Once start() returned a handle, the service owns an accepted live
|
||||
// attempt; a hostile later accessor or bind must not make that attempt
|
||||
// unreachable. The wrapper also memoizes provider disposal, so automatic
|
||||
// rollback and a racing caller join one quiescence transaction even if a
|
||||
// contract-violating provider forgot to make its own method idempotent.
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const inputDispose = acceptedRun.dispose
|
||||
if (typeof inputDispose !== 'function') {
|
||||
throw new TypeError(`subagent provider "${name}" run dispose must be a function`)
|
||||
}
|
||||
let disposal: Promise<void> | undefined
|
||||
const dispose = (): Promise<void> => {
|
||||
if (disposal === undefined) {
|
||||
// Claim the shared transaction before invoking provider code: a raw
|
||||
// disposer can synchronously reenter this wrapper through a reference
|
||||
// retained by its caller, and both calls must join one provider call.
|
||||
const claimed = Promise.withResolvers<undefined>()
|
||||
disposal = claimed.promise
|
||||
try {
|
||||
// Invoke through the captured callable without reading its public
|
||||
// `bind`/`length`/`name` properties. Disposal is the recovery
|
||||
// capability itself; hostile function metadata must not prevent the
|
||||
// seam from exercising it when a later handle field is malformed.
|
||||
const returned: unknown = Reflect.apply(inputDispose, acceptedRun, [])
|
||||
// A raw disposer can reenter the service wrapper and directly return
|
||||
// that same shared promise. Awaiting it here would make the promise
|
||||
// depend on itself forever; reject the cyclic provider contract loud.
|
||||
if (returned === claimed.promise) {
|
||||
claimed.reject(new TypeError(`subagent provider "${name}" run dispose returned its own wrapper disposal promise`))
|
||||
return disposal
|
||||
}
|
||||
void Promise.resolve(returned).then(
|
||||
() => { claimed.resolve(undefined) },
|
||||
(error: unknown) => { claimed.reject(error) },
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
claimed.reject(error instanceof Error
|
||||
? error
|
||||
: new Error('subagent provider run dispose threw a non-Error value', { cause: error }))
|
||||
}
|
||||
}
|
||||
return disposal
|
||||
}
|
||||
// Provider-owned run objects can be accessor-backed too. Capture every
|
||||
// public field exactly once, bind methods to the provider's original handle,
|
||||
// and expose only this service-owned wrapper. The normalized result promise
|
||||
// is also the one lifecycle telemetry observes, so the caller and observers
|
||||
// cannot receive different values from stateful accessors.
|
||||
try {
|
||||
const id = acceptedRun.id
|
||||
if (typeof id !== 'string') {
|
||||
throw new TypeError(`subagent provider "${name}" run id must be a string`)
|
||||
}
|
||||
const started = acceptedRun.started
|
||||
if (!(started instanceof Promise)) {
|
||||
throw new TypeError(`subagent provider "${name}" run started must be a Promise`)
|
||||
}
|
||||
// Observe each accepted provider promise before reading the next hostile
|
||||
// field. A later accessor/validation failure prevents a wrapper from being
|
||||
// returned, but must not leave an already-rejected provider promise
|
||||
// unhandled while rollback proceeds.
|
||||
void started.catch(() => undefined)
|
||||
const providerResult = acceptedRun.result
|
||||
if (!(providerResult instanceof Promise)) {
|
||||
throw new TypeError(`subagent provider "${name}" run result must be a Promise`)
|
||||
}
|
||||
void providerResult.catch(() => undefined)
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const inputCancel = acceptedRun.cancel
|
||||
if (typeof inputCancel !== 'function') {
|
||||
throw new TypeError(`subagent provider "${name}" run cancel must be a function`)
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const inputSendMessage = acceptedRun.sendMessage
|
||||
if (inputSendMessage !== undefined && typeof inputSendMessage !== 'function') {
|
||||
throw new TypeError(`subagent provider "${name}" run sendMessage must be a function when provided`)
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const inputResume = acceptedRun.resume
|
||||
if (inputResume !== undefined && typeof inputResume !== 'function') {
|
||||
throw new TypeError(`subagent provider "${name}" run resume must be a function when provided`)
|
||||
}
|
||||
const cancel = Function.prototype.bind.call(inputCancel, acceptedRun) as SubagentRun['cancel']
|
||||
const sendMessage = inputSendMessage === undefined
|
||||
? undefined
|
||||
: Function.prototype.bind.call(inputSendMessage, acceptedRun) as NonNullable<SubagentRun['sendMessage']>
|
||||
const resume = inputResume === undefined
|
||||
? undefined
|
||||
: Function.prototype.bind.call(inputResume, acceptedRun) as NonNullable<SubagentRun['resume']>
|
||||
const result = providerResult.then(async (value) => {
|
||||
try {
|
||||
return this.snapshotRunResult(value)
|
||||
} catch (error: unknown) {
|
||||
// A malformed terminal value is an infrastructure contract fault. The
|
||||
// result rejects only after the accepted provider attempt has reached
|
||||
// quiescence, so a caller cannot lose the only cleanup handle by merely
|
||||
// observing the normalization failure.
|
||||
await this.rollbackProviderRun(name, dispose)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
const run: SubagentRun = Object.freeze({
|
||||
id,
|
||||
started,
|
||||
result,
|
||||
cancel,
|
||||
dispose,
|
||||
...sendMessage === undefined
|
||||
? {}
|
||||
: { sendMessage },
|
||||
...resume === undefined
|
||||
? {}
|
||||
: { resume },
|
||||
})
|
||||
|
||||
// Observe result settlement IMMEDIATELY, before waiting on readiness. A
|
||||
// provider may fail both promises in the same turn; deferring the rejection
|
||||
// handler until `started` fulfilled would leave `result` transiently
|
||||
// unhandled. The settled event is buffered until start has been announced,
|
||||
// preserving start → end order even for an already-settled scripted run.
|
||||
let readiness: 'pending' | 'started' | 'failed' = 'pending'
|
||||
let pendingEnd: SubagentRunEndInfo | undefined
|
||||
const deliverEnd = (info: SubagentRunEndInfo): void => {
|
||||
if (readiness === 'started') this.emitLifecycle('subagent/end', info, parent)
|
||||
else if (readiness === 'pending') pendingEnd = info
|
||||
// A pre-publication readiness failure has no lifecycle pair; result
|
||||
// remains observable by the run's consumer, but telemetry must not claim
|
||||
// that a child started.
|
||||
}
|
||||
void result.then(
|
||||
(value) => {
|
||||
deliverEnd({
|
||||
provider: name,
|
||||
id,
|
||||
stopReason: value.stopReason,
|
||||
lastAssistantMessage: value.output,
|
||||
})
|
||||
},
|
||||
() => { deliverEnd({ provider: name, id, stopReason: 'error' }) },
|
||||
)
|
||||
|
||||
// Readiness is the publication boundary owned by the provider. For
|
||||
// in-process runs, fulfillment means the agent registry already contains
|
||||
// `run.id`; for ACP it means the remote session exists. Emit start with
|
||||
// per-listener containment, then flush an outcome that settled unusually
|
||||
// early. A readiness rejection is handled here and deliberately emits no
|
||||
// false start/end pair; the result path above remains independently handled.
|
||||
void started.then(
|
||||
() => {
|
||||
readiness = 'started'
|
||||
this.emitLifecycle('subagent/start', { provider: name, id }, parent)
|
||||
if (pendingEnd !== undefined) {
|
||||
const info = pendingEnd
|
||||
pendingEnd = undefined
|
||||
this.emitLifecycle('subagent/end', info, parent)
|
||||
}
|
||||
},
|
||||
() => {
|
||||
readiness = 'failed'
|
||||
pendingEnd = undefined
|
||||
},
|
||||
)
|
||||
return run
|
||||
} catch (error: unknown) {
|
||||
// start() has already transferred a live attempt to the seam. Begin
|
||||
// rollback synchronously before surfacing the malformed-handle failure;
|
||||
// the contained cleanup promise prevents either a resource leak or an
|
||||
// unhandled rejection even though this API cannot synchronously await it.
|
||||
void this.rollbackProviderRun(name, dispose)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispose one malformed provider attempt without letting cleanup mask the contract fault. */
|
||||
private async rollbackProviderRun(providerName: string, dispose: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await dispose()
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`subagent provider "${providerName}" malformed run rollback failed: ${renderThrown(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize one provider result into the immutable seam value. */
|
||||
private snapshotRunResult(value: SubagentResult): SubagentResult {
|
||||
// Capture every provider-owned field once before validation. In particular,
|
||||
// lifecycle telemetry must not reread accessors after the caller receives
|
||||
// the result and observe a different terminal outcome.
|
||||
const output = value.output
|
||||
const structured = value.structured
|
||||
const stopReason = value.stopReason
|
||||
if (!Array.isArray(output)) {
|
||||
throw new TypeError('subagent result output must be an array')
|
||||
}
|
||||
if (typeof stopReason !== 'string') {
|
||||
throw new TypeError('subagent result stopReason must be a string')
|
||||
}
|
||||
const accepted: SubagentResult = {
|
||||
output,
|
||||
...structured === undefined ? {} : { structured },
|
||||
stopReason,
|
||||
}
|
||||
const snapshot = snapshotJsonValue(accepted)
|
||||
if (snapshot === undefined) {
|
||||
throw new TypeError('subagent result must be losslessly JSON-serializable')
|
||||
}
|
||||
return deepFreeze(snapshot)
|
||||
}
|
||||
|
||||
/** Read one coherent caller request into immutable data properties. */
|
||||
private snapshotStartRequest(request: SubagentStartRequest): Readonly<SubagentStartRequest> {
|
||||
const prompt = request.prompt
|
||||
const parent = request.parent
|
||||
const signal = request.signal
|
||||
const agentOptions = request.agentOptions
|
||||
const outputSchema = request.outputSchema
|
||||
const maxDepth = request.maxDepth
|
||||
const toolFilter = request.toolFilter
|
||||
const persona = request.persona
|
||||
return Object.freeze({
|
||||
prompt,
|
||||
parent,
|
||||
...signal !== undefined ? { signal } : {},
|
||||
...agentOptions !== undefined ? { agentOptions } : {},
|
||||
...outputSchema !== undefined ? { outputSchema } : {},
|
||||
...maxDepth !== undefined ? { maxDepth } : {},
|
||||
...toolFilter !== undefined ? { toolFilter } : {},
|
||||
...persona !== undefined ? { persona } : {},
|
||||
})
|
||||
const run = await provider.start(request)
|
||||
// Attach the terminal observer before dispatching start. Promise reactions
|
||||
// still run after this synchronous start emission, preserving start → end.
|
||||
void run.result.then(
|
||||
(result) => {
|
||||
this.emitLifecycle('subagent/end', {
|
||||
provider: name,
|
||||
id: run.id,
|
||||
stopReason: result.stopReason,
|
||||
lastAssistantMessage: result.output,
|
||||
}, parent)
|
||||
},
|
||||
() => {
|
||||
this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }, parent)
|
||||
},
|
||||
)
|
||||
this.emitLifecycle('subagent/start', { provider: name, id: run.id }, parent)
|
||||
return run
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a `subagent/*` lifecycle event with PER-LISTENER containment: dispatch
|
||||
* each subscriber individually and log (never propagate) either a synchronous
|
||||
* throw or a returned-promise rejection, so one bad subscriber can neither
|
||||
* strand the already-live run, surface as an unhandled rejection on the
|
||||
* detached settle hook, NOR starve the listeners registered after it. Async
|
||||
* listeners remain concurrent fire-and-forget; dispatch does not await or
|
||||
* serialize them. A single try/catch around `ctx.emit` would not do the
|
||||
* last part — cordis `emit` runs listeners in a `.map(cb => cb())` that halts
|
||||
* on the first throw — so this resolves the listener callbacks via
|
||||
* `ctx.events.dispatch` and contains each call, the same guarantee
|
||||
* `BashExecutor.notifyTaskDone` gives its own listener set.
|
||||
*
|
||||
* `subagent/provider-removed` routes through here too: it fires inside the
|
||||
* provider registration's DISPOSER, where a propagating listener would
|
||||
* disrupt the backend fiber's teardown (dispose must reach quiescence) and a
|
||||
* starved later listener would leave a mirror consumer (`dsh-tool-subagent`)
|
||||
* holding a tool for a provider that no longer exists. `subagent/provider-added`
|
||||
* deliberately does NOT: it fires at registration time, where a throwing
|
||||
* listener unwinds the yielded rollback — the same fail-loud register-time
|
||||
* semantics as the system-prompt registries.
|
||||
* Emit lifecycle events with per-listener synchronous and asynchronous
|
||||
* exception containment. Payloads are borrowed immutable values.
|
||||
*/
|
||||
private emitLifecycle(name: 'subagent/start', info: SubagentRunInfo, parent: Agent): void
|
||||
private emitLifecycle(name: 'subagent/end', info: SubagentRunEndInfo, parent: Agent): void
|
||||
@@ -639,21 +216,12 @@ export class SubagentService extends Service {
|
||||
info: SubagentRunInfo | SubagentRunEndInfo | string,
|
||||
parent?: Agent,
|
||||
): void {
|
||||
// Run lifecycle events dispatch in the DELEGATING PARENT's scope (a
|
||||
// parent-scoped listener observes only its own delegations); the
|
||||
// provider-removed registry notification stays unfiltered. The carrier is
|
||||
// args[0] of the dispatch call, exactly as cordis' own emit spells it.
|
||||
const acceptedInfo = typeof info === 'string' ? info : deepFreeze(info)
|
||||
const dispatchArgs: unknown[] = parent === undefined
|
||||
? [name, acceptedInfo]
|
||||
: [scopeTarget(this, parent), name, acceptedInfo]
|
||||
? [name, info]
|
||||
: [scopeTarget(this, parent), name, info]
|
||||
for (const callback of this.ctx.events.dispatch('emit', dispatchArgs)) {
|
||||
try {
|
||||
const returned: unknown = callback(acceptedInfo)
|
||||
// Plain emits remain fire-and-forget and every callback is still invoked
|
||||
// synchronously in this loop. Observe a returned promise independently so
|
||||
// an async listener rejection is contained without serializing listeners
|
||||
// or delaying provider/run lifecycle.
|
||||
const returned: unknown = callback(info)
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
this.ctx.logger.warn(`subagent: ${name} listener rejected: ${renderThrown(error)}`)
|
||||
})
|
||||
@@ -663,11 +231,7 @@ export class SubagentService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
*/
|
||||
/** Reject the first requested capability that the provider lacks. */
|
||||
private assertCapabilities(provider: SubagentProvider, request: SubagentStartRequest): void {
|
||||
const needs: { when: boolean; cap: keyof SubagentCapabilities }[] = [
|
||||
{ when: request.outputSchema !== undefined, cap: 'outputSchema' },
|
||||
@@ -686,7 +250,7 @@ export class SubagentService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
/** Render an arbitrary thrown value without allowing coercion to throw again. */
|
||||
/** Render any listener-thrown value without letting coercion escape containment. */
|
||||
function renderThrown(value: unknown): string {
|
||||
try {
|
||||
return value instanceof Error ? `${value.name}: ${value.message}` : String(value)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import type { Agent, AgentId, AgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
|
||||
import type { StructuredOutputSchema, ToolRestriction } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/**
|
||||
* Which START-TIME features a provider supports. Checked by the service
|
||||
@@ -24,13 +24,13 @@ import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
|
||||
*/
|
||||
export interface SubagentCapabilities {
|
||||
/** Honor {@link SubagentStartRequest.outputSchema} (structured final output). */
|
||||
outputSchema: boolean
|
||||
readonly outputSchema: boolean
|
||||
/** Enforce {@link SubagentStartRequest.maxDepth} (recursion cap). */
|
||||
depthLimit: boolean
|
||||
readonly depthLimit: boolean
|
||||
/** Enforce {@link SubagentStartRequest.toolFilter} (child tool scoping). */
|
||||
toolFilter: boolean
|
||||
readonly toolFilter: boolean
|
||||
/** Honor {@link SubagentStartRequest.persona} (a per-child persona). */
|
||||
persona: boolean
|
||||
readonly persona: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,22 +41,24 @@ export interface SubagentCapabilities {
|
||||
*/
|
||||
export interface SubagentStartRequest {
|
||||
/** The task/prompt for the child agent (a user message in the child session). */
|
||||
prompt: ContentBlock[]
|
||||
readonly 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
|
||||
readonly 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.
|
||||
* This is the canonical cancellation channel both before and after startup:
|
||||
* a provider rejects `start()` after cleaning partial resources when it
|
||||
* fires before publication, and cancels a published child when it fires
|
||||
* afterward.
|
||||
*/
|
||||
signal?: AbortSignal
|
||||
readonly signal: AbortSignal
|
||||
/** Per-child agent options (model, system prompt). */
|
||||
agentOptions?: AgentOptions
|
||||
readonly agentOptions?: AgentOptions
|
||||
/**
|
||||
* Optional structured-output schema — an object-rooted JSON Schema within the
|
||||
* enforced subset (see `assertSupportedOutputSchema` in dsh-tools; a schema
|
||||
@@ -67,14 +69,14 @@ export interface SubagentStartRequest {
|
||||
* data — a caller holding foreign-realm data materializes it first.
|
||||
* Requesting it against a provider that lacks the capability is rejected at start.
|
||||
*/
|
||||
outputSchema?: StructuredOutputSchema
|
||||
readonly outputSchema?: StructuredOutputSchema
|
||||
/**
|
||||
* Optional absolute delegation-depth cap for the child being started: its
|
||||
* computed depth must be less than or equal to this non-negative safe
|
||||
* integer. Requires {@link SubagentCapabilities.depthLimit}; rejected at
|
||||
* start otherwise.
|
||||
*/
|
||||
maxDepth?: number
|
||||
readonly maxDepth?: number
|
||||
/**
|
||||
* Optional child tool scoping. Requires {@link SubagentCapabilities.toolFilter};
|
||||
* rejected at start otherwise. In-process backends apply it as a scoped
|
||||
@@ -82,7 +84,7 @@ export interface SubagentStartRequest {
|
||||
* from the child's prompt AND refuse to execute (one visibility), with loud
|
||||
* unknown-name validation.
|
||||
*/
|
||||
toolFilter?: { allow?: string[]; deny?: string[] }
|
||||
readonly toolFilter?: ToolRestriction
|
||||
/**
|
||||
* Optional per-child persona. Requires {@link SubagentCapabilities.persona};
|
||||
* rejected at start otherwise. In-process backends register it as a scoped
|
||||
@@ -90,7 +92,7 @@ export interface SubagentStartRequest {
|
||||
* persona for this child alone — same template semantics as the deployment
|
||||
* persona (strict `{{…}}` interpolation against the registered variables).
|
||||
*/
|
||||
persona?: string
|
||||
readonly persona?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,7 +104,7 @@ export interface SubagentStartRequest {
|
||||
export interface SubagentStopReasonMap {
|
||||
/** The child finished its turn normally. */
|
||||
completed: 'completed'
|
||||
/** The run was cancelled (parent signal, explicit `cancel()`, or peer cancel). */
|
||||
/** The run was cancelled by its request signal or by disposal. */
|
||||
aborted: 'aborted'
|
||||
/** The child failed (model error, transport error). */
|
||||
error: 'error'
|
||||
@@ -120,7 +122,7 @@ export type SubagentStopReason = SubagentStopReasonMap[keyof SubagentStopReasonM
|
||||
*/
|
||||
export interface SubagentResult {
|
||||
/** The child's final assistant output (the last assistant message's content). */
|
||||
output: ContentBlock[]
|
||||
readonly output: ContentBlock[]
|
||||
/**
|
||||
* The structured result after a requested `outputSchema` was successfully
|
||||
* satisfied. Requesting a schema does not guarantee presence: a provider can
|
||||
@@ -128,32 +130,24 @@ export interface SubagentResult {
|
||||
* valid capture. Shape is validated against the request schema by the
|
||||
* provider; `unknown` here because the seam is schema-agnostic.
|
||||
*/
|
||||
structured?: unknown
|
||||
readonly structured?: unknown
|
||||
/** Why the run ended. A non-`completed` reason means `output` may be partial. */
|
||||
stopReason: SubagentStopReason
|
||||
readonly 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).
|
||||
* Returned by {@link SubagentProvider.start} (via the service) only after the
|
||||
* child is ready. The consumer awaits {@link result} and MUST {@link dispose}
|
||||
* on every path to cancel any remaining work and reach child quiescence.
|
||||
*
|
||||
* {@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 (local in-process runs publish it in `ctx.agents`; remote transports need not). */
|
||||
/** The child agent's id (local in-process runs are already published in `ctx.agents`; remote transports need not publish locally). */
|
||||
readonly id: AgentId
|
||||
/**
|
||||
* The provider's publication/readiness boundary. Resolves only after a real
|
||||
* child is established: an in-process agent is live in `ctx.agents`, or a
|
||||
* remote transport has created its child session. Rejects when the attempt
|
||||
* fails or is cancelled before that boundary. The service emits the paired
|
||||
* `subagent/start`/`subagent/end` lifecycle only after this fulfills.
|
||||
*/
|
||||
readonly started: Promise<void>
|
||||
/**
|
||||
* Resolves with the child's terminal {@link SubagentResult} when the run
|
||||
* settles. Does NOT reject on a child-level failure — a model/transport
|
||||
@@ -162,12 +156,10 @@ export interface SubagentRun {
|
||||
* 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.
|
||||
* Cancel remaining work, reach child quiescence, and release the run's
|
||||
* resources (in-process: dispose the owned agent and remove its session;
|
||||
* ACP: kill and reap the subprocess). Idempotent.
|
||||
*/
|
||||
dispose(): Promise<void>
|
||||
/**
|
||||
@@ -179,7 +171,7 @@ export interface SubagentRun {
|
||||
* 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
|
||||
resume?(content: ContentBlock[]): Promise<SubagentRun>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,8 +179,8 @@ export interface SubagentRun {
|
||||
* 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). The
|
||||
* service freezes the public descriptor and callback identity at registration;
|
||||
* the captured `start` remains bound to the original provider receiver.
|
||||
* Providers are trusted same-process implementations; callers treat their
|
||||
* descriptors and returned values as borrowed immutable data.
|
||||
*/
|
||||
export interface SubagentProvider {
|
||||
/** Unique registry name (e.g. `spawn`, `fork`, `acp`). */
|
||||
@@ -208,12 +200,12 @@ export interface SubagentProvider {
|
||||
*/
|
||||
readonly inheritsParentContext: boolean
|
||||
/**
|
||||
* Start preparing a child run and return its handle synchronously. The
|
||||
* Establish a child and return its handle only after publication. 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. The returned {@link SubagentRun.started} must mark
|
||||
* the real publication/readiness boundary; the result path must observe that
|
||||
* promise immediately so a pre-start rejection cannot become unhandled.
|
||||
* honorable when present. If setup fails or `request.signal` aborts before
|
||||
* fulfillment, the provider owns and cleans all partial resources before this
|
||||
* promise rejects. Ownership transfers to the caller only on fulfillment.
|
||||
*/
|
||||
start(request: SubagentStartRequest): SubagentRun
|
||||
start(request: SubagentStartRequest): Promise<SubagentRun>
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,9 +17,6 @@
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user