docs: trim generated prose
This commit is contained in:
@@ -1,20 +1,7 @@
|
||||
/**
|
||||
* The out-of-process ACP subagent backend: registers a {@link SubagentProvider}
|
||||
* on `ctx.subagents` that runs each child agent in a SPAWNED SUBPROCESS, driven
|
||||
* over the Agent Client Protocol (ACP) as the client. The parent process is the
|
||||
* ACP client; the child is any ACP agent (point the configured command at the
|
||||
* `acp-agent` example to "talk to our own process").
|
||||
*
|
||||
* Unlike the in-process backends (`-spawn`/`-fork`), the child does NOT share
|
||||
* this cordis context — it is a separate process with its own session, model
|
||||
* client, and tools. So this backend injects only `subagents` (no `agents`),
|
||||
* advertises NO start-time capabilities (an out-of-process child cannot enforce
|
||||
* the parent's depth/tool-filter), and ignores `request.parent`.
|
||||
*
|
||||
* Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default
|
||||
* export (the cordis Loader's `unwrapExports` does `exports.default ?? exports`,
|
||||
* so a stray default would drop the namespace — see docs/postmortem/0001).
|
||||
*
|
||||
* The out-of-process ACP subagent backend: registers a {@link SubagentProvider} on
|
||||
* `ctx.subagents` that runs each child agent in a spawned SUBPROCESS, driven over the Agent
|
||||
* Client Protocol (ACP) as the client.
|
||||
* @module @deepseek-ai/dsh-subagent-acp
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,24 +1,5 @@
|
||||
/**
|
||||
* The out-of-process ACP subagent run driver. Spawns a child agent as a
|
||||
* subprocess, speaks the Agent Client Protocol (ACP) to it over stdio as the
|
||||
* CLIENT, drives one session to completion, and shapes the result into a
|
||||
* {@link SubagentResult}. The mirror image of the server-side bridge in
|
||||
* `@deepseek-ai/dsh-acp` (which is the ACP *agent* side): here we are the ACP
|
||||
* *client*, so we CALL `initialize`/`newSession`/`prompt`/`cancel` and we
|
||||
* IMPLEMENT the `Client` callbacks (`sessionUpdate`, `requestPermission`).
|
||||
*
|
||||
* One subprocess per run (fresh-process-per-run): `start` spawns, runs exactly
|
||||
* one ACP session, and `dispose` kills the subprocess and awaits its exit.
|
||||
* Persistent-process pooling is a future optimization (see the RFC).
|
||||
*
|
||||
* TODO(acp-subagent-replay): snapshot-tier coverage of an ACP child is a
|
||||
* distinct replay shape — each child is its own PROCESS with its own
|
||||
* single-agent replay (the child boots under `DSH_SNAPSHOT=replay` with its own
|
||||
* sessions-root + fixture), unlike the in-process per-session keying in
|
||||
* `dsh-llm-replay`. Deferred to a follow-up; keyless coverage here is via a
|
||||
* scripted mock ACP server subprocess, and the with-key e2e drives the real
|
||||
* `acp-agent` example. See the ACP-subagent-backend RFC.
|
||||
*
|
||||
* The out-of-process ACP subagent run driver.
|
||||
* @module @deepseek-ai/dsh-subagent-acp/run
|
||||
*/
|
||||
|
||||
@@ -96,16 +77,9 @@ export interface AcpRunSpec {
|
||||
}
|
||||
|
||||
/**
|
||||
* Default grace for the child's EOF-driven quiesce on dispose (the
|
||||
* `disposeEofGraceMs` config) — the window for it to flush persistence and tear
|
||||
* down its OWN nested subprocesses (which may run their own `SIGTERM`→`SIGKILL`
|
||||
* escalation) before the parent escalates to a signal. Deliberately LARGER than
|
||||
* {@link DEFAULT_DISPOSE_GRACE_MS}: a cooperative child whose teardown is itself
|
||||
* waiting on a signal-trapping grandchild (e.g. a bash subprocess in its own ~3s
|
||||
* SIGTERM→SIGKILL grace) plus a final flush needs MORE than a single
|
||||
* signal-grace of headroom, or the parent's SIGTERM cuts it off exactly as it
|
||||
* reaches its own SIGKILL+flush. The child is an arbitrary ACP agent, so this is
|
||||
* a standalone generous default, NOT derived from any child's internals.
|
||||
* Default grace for the child's EOF-driven quiesce on dispose (the `disposeEofGraceMs` config)
|
||||
* — the window for it to flush persistence and tear down its own nested subprocesses (which
|
||||
* may run their own `SIGTERM`→`SIGKILL` escalation) before the parent escalates to a signal.
|
||||
*/
|
||||
export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000
|
||||
|
||||
@@ -176,13 +150,6 @@ function toError(value: unknown): Error {
|
||||
/**
|
||||
* Start an out-of-process ACP child for `request` and return a {@link SubagentRun}.
|
||||
*
|
||||
* Spawns the configured command, wraps its stdio in an ACP `ClientSideConnection`,
|
||||
* and drives one session: `initialize` → `newSession` → `prompt`. The accumulated
|
||||
* `agent_message_chunk` text is the result output; the prompt's terminal
|
||||
* `StopReason` maps to the stop reason. `result` never REJECTS on a child-level
|
||||
* failure (a spawn/transport/RPC error resolves with `stopReason: 'error'`), per
|
||||
* the seam contract. `cancel()` sends `session/cancel`; `dispose()` kills the
|
||||
* subprocess and awaits its exit (quiescent teardown).
|
||||
* @param request - the start request; the driver consumes `prompt` and `signal`
|
||||
* (an already-aborted signal yields an inert `aborted` run with no spawn).
|
||||
* @param spec - the resolved spawn spec: command/args/cwd, env, permission
|
||||
@@ -227,12 +194,8 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
|
||||
|
||||
// Accumulate the child's streamed assistant text — the SubagentResult output.
|
||||
const output: string[] = []
|
||||
// `cancelled` records that a cancel was requested (signal or cancel()), so a
|
||||
// run torn down before the prompt resolves settles `aborted` rather than the
|
||||
// generic error mapping. Held on a mutable object so the async closures that
|
||||
// set it (the abort listener) and the IIFE that reads it don't fight TS's
|
||||
// control-flow narrowing of a bare `let` (which would type the catch-time read
|
||||
// as always-`false`).
|
||||
// `cancelled` records that a cancel was requested (signal or cancel()), so a run torn down
|
||||
// before the prompt resolves settles `aborted` rather than the generic error mapping.
|
||||
const flags = { cancelled: false }
|
||||
|
||||
const makeClient = (_agent: AcpAgent): Client => ({
|
||||
@@ -268,27 +231,14 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
|
||||
)
|
||||
|
||||
let sessionId: string | undefined
|
||||
// Resolves when a cancel is requested, so `result` can settle `aborted` even
|
||||
// if the child never cooperates with `session/cancel` (it ignores the notify,
|
||||
// or the prompt wedges). The result path races this against the ACP drive: the
|
||||
// FIRST to settle wins, so `cancel()` always honors the contract (`result`
|
||||
// settles `aborted`) without waiting on a non-cooperative child. `dispose`
|
||||
// still kills the process and reaps it; this only unblocks `result`. The
|
||||
// executor runs synchronously, so `signalCancelSettled` is assigned before the
|
||||
// Promise constructor returns (the `!` asserts the definite assignment).
|
||||
// Resolves when a cancel is requested, so `result` can settle `aborted` even if the child
|
||||
// never cooperates with `session/cancel` (it ignores the notify, or the prompt wedges).
|
||||
let signalCancelSettled!: () => void
|
||||
const cancelSettled = new Promise<void>((resolve) => { signalCancelSettled = resolve })
|
||||
const requestCancel = (): void => {
|
||||
flags.cancelled = true
|
||||
signalCancelSettled()
|
||||
// Best-effort: tell the child to cancel the in-flight turn. Swallows a
|
||||
// rejection — the session may not exist yet, or the pipe may be gone; the
|
||||
// dispose path kills the process regardless. If the session has NOT been
|
||||
// created yet (cancel raced ahead of `newSession`), the `cancelled` flag
|
||||
// alone carries it: the result path re-checks the flag after each await and
|
||||
// settles `aborted` without running the prompt. The `.catch` swallow is
|
||||
// defensive for a narrow transport race (child gone mid-send) — v8-ignored
|
||||
// because dispose kills the process regardless, so it can't be hit in tests.
|
||||
// Best-effort: tell the child to cancel the in-flight turn.
|
||||
/* v8 ignore next */
|
||||
if (sessionId !== undefined) void conn.cancel({ sessionId }).catch(() => { /* child gone / no session */ })
|
||||
}
|
||||
@@ -303,12 +253,8 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
|
||||
return text.length > 0 ? [{ type: 'text', text }] : []
|
||||
}
|
||||
|
||||
// A provider is "started" only once the remote child has completed ACP
|
||||
// initialization and published a session. SubagentService gates its
|
||||
// `subagent/start` notification on this boundary, just as the in-process
|
||||
// provider gates it on local Agent publication. Failure or cancellation
|
||||
// before this point rejects readiness and therefore produces no paired
|
||||
// lifecycle events for a child that never became live.
|
||||
// A provider is "started" only once the remote child has completed ACP initialization and
|
||||
// published a session.
|
||||
const started: Promise<void> = Promise.race([
|
||||
(async (): Promise<void> => {
|
||||
await conn.initialize({
|
||||
@@ -327,20 +273,13 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
|
||||
|
||||
const result: Promise<SubagentResult> = (async (): Promise<SubagentResult> => {
|
||||
try {
|
||||
// Readiness is the initialize → newSession phase above. Awaiting the SAME
|
||||
// promise immediately observes its rejection even without the service,
|
||||
// and guarantees the prompt phase never starts before the provider can
|
||||
// truthfully announce a live child.
|
||||
// Readiness is the initialize → newSession phase above.
|
||||
await started
|
||||
|
||||
// Race two post-start outcomes, first to settle wins:
|
||||
// - prompt: the normal remote turn;
|
||||
// - cancelSettled: a cancel was requested — settle `aborted` immediately
|
||||
// rather than waiting on a child that may ignore `session/cancel` or
|
||||
// wedge the prompt (the `cancel()` contract: `result` settles `aborted`).
|
||||
// A spawn error can only precede readiness and is already one arm of
|
||||
// `started`; after `newSession` succeeds, transport/process failure rejects
|
||||
// the in-flight prompt RPC through the connection.
|
||||
// Race two post-start outcomes, first to settle wins: - prompt: the normal remote turn; -
|
||||
// cancelSettled: a cancel was requested — settle `aborted` immediately rather than
|
||||
// waiting on a child that may ignore `session/cancel` or wedge the prompt (the `cancel()`
|
||||
// contract: `result` settles `aborted`).
|
||||
const prompt = async (): Promise<SubagentResult> => {
|
||||
// `started` cannot fulfill without assigning the session id; the cast
|
||||
// records that local invariant without an unreachable defensive arm.
|
||||
@@ -353,12 +292,7 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
|
||||
])
|
||||
} catch (error: unknown) {
|
||||
if (flags.cancelled) return { output: collectOutput(), stopReason: 'aborted' }
|
||||
// The seam contract: result resolves (never rejects) on a child-level
|
||||
// failure. A cancellation is recognized by the flag above even when it
|
||||
// wins during readiness; every other rejection is a genuine child-level
|
||||
// error — initialize/newSession/prompt transport/RPC failure or ENOENT.
|
||||
// Flatten to `error` and surface the original via onError so a real fault
|
||||
// is preserved rather than silently lost.
|
||||
// The seam contract: result resolves (never rejects) on a child-level failure.
|
||||
try {
|
||||
spec.onError?.(toError(error), 'error')
|
||||
} catch {
|
||||
@@ -379,15 +313,8 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
|
||||
},
|
||||
async dispose(): Promise<void> {
|
||||
request.signal?.removeEventListener('abort', onAbort)
|
||||
// Quiescent teardown via the shared ladder (stdin EOF → SIGTERM →
|
||||
// SIGKILL, awaiting the actual exit). For THIS child the EOF tier is the
|
||||
// one that matters: our acp-agent has NO SIGTERM handler in a normal
|
||||
// session — it tears down via the server bridge's connection-close path
|
||||
// (conn.closed → per-agent dispose → final session/flush), driven by the
|
||||
// stdin EOF, NOT by a signal — and a prompt response can resolve from a
|
||||
// turn/end BEFORE that post-turn flush lands, so the child still has
|
||||
// durable work owed when dispose runs (hence the wide EOF grace; see
|
||||
// DEFAULT_DISPOSE_EOF_GRACE_MS).
|
||||
// Quiescent teardown via the shared ladder (stdin EOF → SIGTERM → SIGKILL, awaiting the
|
||||
// actual exit).
|
||||
await disposeChildProcess(child, {
|
||||
disposeEofGraceMs: spec.disposeEofGraceMs,
|
||||
disposeGraceMs: spec.disposeGraceMs,
|
||||
|
||||
@@ -1,44 +1,7 @@
|
||||
/**
|
||||
* A minimal mock ACP AGENT, run as a subprocess, for the keyless
|
||||
* `dsh-subagent-acp` tests. It speaks the agent side of ACP over stdio and is
|
||||
* fully scripted by environment variables — no model, no network:
|
||||
*
|
||||
* - `MOCK_TEXT` — the assistant text it streams as one `agent_message_chunk`.
|
||||
* - `MOCK_STOP` — the ACP `StopReason` it returns from `prompt`
|
||||
* (`end_turn` default, or `max_tokens`/`refusal`/…).
|
||||
* - `MOCK_HANG` — if `1`, `prompt` never resolves on its own (it waits for
|
||||
* a `session/cancel`), to exercise the client's cancel path.
|
||||
* - `MOCK_IGNORE_CANCEL` — if `1` (with MOCK_HANG), the agent receives
|
||||
* `session/cancel` but NEVER resolves the pending prompt
|
||||
* and never exits — a non-cooperative child. The backend's
|
||||
* `result` must still settle `aborted` on its own and
|
||||
* `dispose()` must still kill the process.
|
||||
* - `MOCK_PERMISSION` — if `1`, the agent calls `session/request_permission`
|
||||
* before answering, to exercise the client's auto-answer.
|
||||
* - `MOCK_READY_FILE` — if set, the path the agent touches once its `prompt`
|
||||
* handler is in flight (it has streamed its chunk). A test
|
||||
* polls for this file to cancel on a CONDITION rather than
|
||||
* an arbitrary timeout (subprocess cold-start is variable).
|
||||
* - `MOCK_FLUSH_ON_EOF` — if set, on stdin EOF the agent takes an async beat
|
||||
* (MOCK_FLUSH_DELAY_MS, default 150) simulating the real
|
||||
* acp-agent's EOF-driven quiesce+flush, then touches this
|
||||
* path and exits ON ITS OWN — no signal. Stands in for a
|
||||
* child whose durable flush completes only if dispose
|
||||
* gives EOF a real window before escalating to SIGTERM.
|
||||
* - `MOCK_IGNORE_EOF` — if `1`, keep the event loop alive past stdin EOF (a bare
|
||||
* timer) but install a SIGTERM handler that exits (and, if
|
||||
* MOCK_SIGTERM_FILE is set, touches it as an observable
|
||||
* proof the SIGTERM rung fired). The child ignores the
|
||||
* graceful EOF window yet dies cooperatively on SIGTERM —
|
||||
* exercising dispose's middle tier (exit during the SIGTERM
|
||||
* grace, before the SIGKILL escalation). Touches
|
||||
* MOCK_READY_FILE once armed.
|
||||
*
|
||||
* It is NOT a test spec (no `describe`/`it`) — it is spawned BY the specs as the
|
||||
* child process the ACP backend drives. Kept as a `.ts` run under tsx by the
|
||||
* spec (which passes its own tsconfig), mirroring how the snapshot harness boots
|
||||
* the real example.
|
||||
*
|
||||
* A minimal mock ACP AGENT, run as a subprocess, for the keyless `dsh-subagent-acp` tests. It
|
||||
* speaks the agent side of ACP over stdio and is fully scripted by environment variables — no
|
||||
* model, no network.
|
||||
* @module @deepseek-ai/dsh-subagent-acp/tests/mock-acp-server
|
||||
*/
|
||||
|
||||
@@ -157,11 +120,8 @@ function makeAgent(conn: AgentSideConnection): Agent {
|
||||
process.exit(1)
|
||||
}
|
||||
if (IGNORE_CANCEL) {
|
||||
// A NON-COOPERATIVE child: receive session/cancel but never resolve the
|
||||
// pending prompt and never exit. The backend's `result` must still settle
|
||||
// `aborted` on its own (the cancel-settle race), and `dispose()` must
|
||||
// still kill the process — proving cancellation does not depend on the
|
||||
// child cooperating.
|
||||
// A NON-COOPERATIVE child: receive session/cancel but never resolve the pending prompt
|
||||
// and never exit.
|
||||
return Promise.resolve()
|
||||
}
|
||||
resolveCancel?.('cancelled')
|
||||
@@ -178,12 +138,9 @@ new AgentSideConnection(
|
||||
),
|
||||
)
|
||||
|
||||
// Under MOCK_TRAP_SIGTERM, ignore SIGTERM and keep stdin open so the process
|
||||
// neither quiesces on EOF nor dies on the graceful signal — exercising the
|
||||
// backend dispose path's SIGKILL escalation. Without this the process exits
|
||||
// normally on SIGTERM / stdin end. Touch READY_FILE once the trap is armed, so
|
||||
// a test waits for that CONDITION before disposing (the trap must be in place,
|
||||
// not merely the process spawned — otherwise SIGTERM hits the default handler).
|
||||
// Under MOCK_TRAP_SIGTERM, ignore SIGTERM and keep stdin open so the process neither quiesces
|
||||
// on EOF nor dies on the graceful signal — exercising the backend dispose path's SIGKILL
|
||||
// escalation.
|
||||
if (process.env.MOCK_TRAP_SIGTERM === '1') {
|
||||
process.on('SIGTERM', () => { /* trapped: refuse to exit on the graceful signal */ })
|
||||
// Keep the event loop alive (a bare timer) so nothing else lets it exit.
|
||||
@@ -191,13 +148,9 @@ if (process.env.MOCK_TRAP_SIGTERM === '1') {
|
||||
if (READY_FILE !== undefined) writeFileSync(READY_FILE, 'trap-armed')
|
||||
}
|
||||
|
||||
// Under MOCK_FLUSH_ON_EOF, model the real acp-agent's EOF-driven quiesce: on
|
||||
// stdin 'end' (the dispose path's `child.stdin.end()`), take an ASYNC beat to
|
||||
// "flush", then touch the marker and exit ON OUR OWN — no signal involved. The
|
||||
// beat is MOCK_FLUSH_DELAY_MS (default 150). A dispose that sends SIGTERM before
|
||||
// the beat completes (no graceful window, or an EOF grace shorter than the
|
||||
// flush) default-terminates this process and the marker is missing; a dispose
|
||||
// that gives the EOF quiesce enough window first lets the flush land.
|
||||
// Under MOCK_FLUSH_ON_EOF, model the real acp-agent's EOF-driven quiesce: on stdin 'end' (the
|
||||
// dispose path's `child.stdin.end()`), take an ASYNC beat to "flush", then touch the marker and
|
||||
// exit ON OUR own — no signal involved.
|
||||
if (FLUSH_ON_EOF !== undefined) {
|
||||
const flushDelayMs = Number(process.env.MOCK_FLUSH_DELAY_MS ?? '150')
|
||||
process.stdin.on('end', () => {
|
||||
@@ -208,14 +161,7 @@ if (FLUSH_ON_EOF !== undefined) {
|
||||
})
|
||||
}
|
||||
|
||||
// Under MOCK_IGNORE_EOF, keep the loop alive past stdin EOF (so the graceful EOF
|
||||
// window times out) but INSTALL A SIGTERM HANDLER that records it and exits — the
|
||||
// child ignores the graceful EOF window yet dies cooperatively on SIGTERM,
|
||||
// exercising dispose's MIDDLE tier (exit during the SIGTERM grace, before the
|
||||
// SIGKILL escalation). When MOCK_SIGTERM_FILE is set the handler touches it, an
|
||||
// OBSERVABLE proof that the SIGTERM rung fired: if dispose skipped the middle
|
||||
// rung and jumped EOF→SIGKILL, SIGKILL is uncatchable so the handler never runs
|
||||
// and the marker is missing. Touch READY_FILE once armed (a test waits on it).
|
||||
// Ignore EOF but exit on SIGTERM to exercise the middle disposal tier.
|
||||
if (process.env.MOCK_IGNORE_EOF === '1') {
|
||||
const sigtermFile = process.env.MOCK_SIGTERM_FILE
|
||||
process.on('SIGTERM', () => {
|
||||
@@ -225,4 +171,3 @@ if (process.env.MOCK_IGNORE_EOF === '1') {
|
||||
setInterval(() => { /* stay alive past EOF until SIGTERM */ }, 1000)
|
||||
if (READY_FILE !== undefined) writeFileSync(READY_FILE, 'ignore-eof-armed')
|
||||
}
|
||||
|
||||
|
||||
@@ -9,16 +9,7 @@ import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import * as acp from '../src/index.ts'
|
||||
|
||||
/**
|
||||
* With-key e2e for the ACP subagent backend: the harness drives ITSELF as an ACP
|
||||
* server. The backend spawns the real `acp-agent` example as a child PROCESS,
|
||||
* speaks ACP to it over stdio, and the child runs the REAL model in its own
|
||||
* process to answer a prompt. We verify the child's real answer comes back
|
||||
* through the seam — the "talk to our own process" smoke the design called for.
|
||||
* Key-gated (self-skips without DEEPSEEK_API_KEY).
|
||||
*
|
||||
* This is the out-of-process analogue of the in-process spawn e2e: there a
|
||||
* parent agent on the same context drove a child; here the child is a separate
|
||||
* process reached over ACP, proving the seam generalizes across the boundary.
|
||||
* With-key e2e for the ACP subagent backend: the harness drives ITSELF as an ACP server.
|
||||
*/
|
||||
|
||||
// The real acp-agent example: its bin + cordis.yml (the live DeepSeek config).
|
||||
|
||||
@@ -188,9 +188,8 @@ describe('dsh-subagent-acp', () => {
|
||||
})
|
||||
|
||||
it('dispose escalates SIGTERM → SIGKILL for a child that traps SIGTERM (bounded quiescence)', async () => {
|
||||
// The child traps SIGTERM and keeps its event loop alive, so a graceful
|
||||
// term alone would hang dispose forever. With a short grace, dispose must
|
||||
// escalate to SIGKILL and return once the process is actually gone.
|
||||
// The child traps SIGTERM and keeps its event loop alive, so a graceful term alone would
|
||||
// hang dispose forever.
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-trap-'))
|
||||
const ready = join(tmp, 'trap-armed')
|
||||
try {
|
||||
@@ -224,15 +223,8 @@ describe('dsh-subagent-acp', () => {
|
||||
})
|
||||
|
||||
it('dispose gives the child an EOF window that outlasts the SIGTERM grace (graceful flush)', async () => {
|
||||
// The real acp-agent flushes ASYNCHRONOUSLY on stdin EOF (its bridge tears
|
||||
// down on connection close, NOT on a signal) — and it has no SIGTERM handler.
|
||||
// Its EOF teardown can itself await a signal-trapping grandchild (a bash
|
||||
// subprocess in its own SIGTERM→SIGKILL grace) plus a flush, so the EOF window
|
||||
// must be a SEPARATE, WIDER grace than the SIGTERM tier — not the same value.
|
||||
// The mock models a flush that takes LONGER than the SIGTERM grace but well
|
||||
// under the EOF grace: it lands only because tier 1 waits eofGraceMs, not
|
||||
// graceMs. (If dispose reused the small SIGTERM grace for the EOF wait — the
|
||||
// round-2 bug — SIGTERM would fire mid-flush and the marker would be missing.)
|
||||
// The real acp-agent flushes ASYNCHRONOUSLY on stdin EOF (its bridge tears down on
|
||||
// connection close, not on a signal) — and it has no SIGTERM handler.
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-eof-'))
|
||||
const ready = join(tmp, 'ready')
|
||||
const flushed = join(tmp, 'flushed')
|
||||
@@ -242,10 +234,7 @@ describe('dsh-subagent-acp', () => {
|
||||
args: ['--import', tsxLoader, mockServer],
|
||||
cwd: process.cwd(),
|
||||
permission: 'reject',
|
||||
// MOCK_HANG so the prompt never resolves on its own — we tear down a live
|
||||
// child. The flush beat (400ms) outlasts the 50ms SIGTERM grace but fits
|
||||
// the 2000ms EOF grace; the marker lands iff the EOF tier honored its own
|
||||
// wider grace.
|
||||
// MOCK_HANG so the prompt never resolves on its own — we tear down a live child.
|
||||
env: {
|
||||
MOCK_HANG: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready,
|
||||
MOCK_FLUSH_ON_EOF: flushed, MOCK_FLUSH_DELAY_MS: '400', TSX_TSCONFIG_PATH: repoTsconfig,
|
||||
@@ -267,12 +256,9 @@ describe('dsh-subagent-acp', () => {
|
||||
})
|
||||
|
||||
it('escalates to SIGTERM for a child that ignores EOF but is not SIGTERM-trapping', async () => {
|
||||
// A child that keeps its loop alive past stdin EOF (so the graceful window
|
||||
// times out) but exits cooperatively on SIGTERM must die on the SIGTERM tier
|
||||
// — dispose returns there, never reaching the SIGKILL tier. The child touches
|
||||
// a SIGTERM marker from its signal handler: SIGKILL is uncatchable, so if
|
||||
// dispose had skipped the middle rung (EOF→SIGKILL) the handler would never
|
||||
// run and the marker would be absent — making this a GENUINE middle-tier guard.
|
||||
// A child that keeps its loop alive past stdin EOF (so the graceful window times out) but
|
||||
// exits cooperatively on SIGTERM must die on the SIGTERM tier — dispose returns there,
|
||||
// never reaching the SIGKILL tier.
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-ignore-eof-'))
|
||||
const ready = join(tmp, 'ready')
|
||||
const sigterm = join(tmp, 'sigterm')
|
||||
@@ -307,9 +293,6 @@ describe('dsh-subagent-acp', () => {
|
||||
|
||||
it('honors a cancel that races AHEAD of newSession (no session id yet) without running the prompt', async () => {
|
||||
// Gate the child at newSession: it signals `ready` and blocks until `go`.
|
||||
// We cancel WHILE newSession is pending (sessionId still undefined, so the
|
||||
// backend cannot send session/cancel) — the `cancelled` flag alone must
|
||||
// settle the run aborted after newSession resolves, never issuing the prompt.
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-early-'))
|
||||
const ready = join(tmp, 'ready')
|
||||
const go = join(tmp, 'go')
|
||||
@@ -457,10 +440,9 @@ describe('dsh-subagent-acp', () => {
|
||||
})
|
||||
|
||||
it('reports a flattened child failure through onError (preserved, not silently lost)', async () => {
|
||||
// The seam forbids `result` rejecting, so a child-level failure is flattened
|
||||
// to a stop reason — onError must still surface the original error so a real
|
||||
// fault is logged, not swallowed. A nonexistent command triggers the spawn
|
||||
// failure path; the spy records the error + the chosen stop reason.
|
||||
// The seam forbids `result` rejecting, so a child-level failure is flattened to a stop
|
||||
// reason — onError must still surface the original error so a real fault is logged, not
|
||||
// swallowed.
|
||||
const errors: { message: string; stopReason: string }[] = []
|
||||
const run = startAcpRun(
|
||||
{ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent },
|
||||
@@ -506,10 +488,8 @@ describe('dsh-subagent-acp', () => {
|
||||
})
|
||||
|
||||
it('settles aborted when the child crashes (tears the pipe) AFTER a cancel', async () => {
|
||||
// The child hangs, we cancel, and instead of answering the child exits hard
|
||||
// — the pending prompt RPC rejects. With a cancel already requested, the
|
||||
// backend's catch path must settle `aborted` (the failure is the cancel
|
||||
// surfacing as a torn pipe), not `error`.
|
||||
// The child hangs, we cancel, and instead of answering the child exits hard — the pending
|
||||
// prompt RPC rejects.
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-crash-'))
|
||||
const ready = join(tmp, 'ready')
|
||||
try {
|
||||
@@ -526,10 +506,7 @@ describe('dsh-subagent-acp', () => {
|
||||
})
|
||||
|
||||
it('settles aborted on cancel even when the child IGNORES session/cancel (non-cooperative)', async () => {
|
||||
// The contract: run.cancel() → result settles `aborted`. A child that hangs
|
||||
// its prompt AND ignores session/cancel must not wedge the parent — the
|
||||
// backend's own cancel-settle path resolves `aborted` without the child's
|
||||
// cooperation, and dispose() still reaps the process.
|
||||
// The contract: run.cancel() → result settles `aborted`.
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-ignorecancel-'))
|
||||
const ready = join(tmp, 'ready')
|
||||
try {
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
# @deepseek-ai/dsh-subagent-fork
|
||||
|
||||
The in-process **fork** subagent backend: a [`SubagentProvider`](../subagent/README.md) that runs each child as a child [`Agent`](../../core/agent) **seeded with a prefix of the parent's session log** — so the child inherits the parent's conversation context instead of starting fresh. Shares the run driver (`startInProcessRun`) with [`dsh-subagent-spawn`](../subagent-spawn/README.md); the only difference is the seed. The shared `run.started` boundary resolves only after the seeded child is published, so `subagent/start` observers see a live registry entry.
|
||||
In-process provider that starts a child [`Agent`](../../core/agent) from the parent's completed conversation prefix. It shares [`startInProcessRun`](../subagent-inprocess/README.md) with the [spawn provider](../subagent-spawn/README.md); the seed is the only backend difference.
|
||||
|
||||
## The seed boundary (the crux)
|
||||
## Seed boundary
|
||||
|
||||
At the moment a subagent tool's `execute` runs, the parent's CURRENT turn is open and unbalanced: the log holds the `assistant/message` carrying this spawn's tool-call and the dangling `tool/call` with no `tool/result` yet. Seeding that raw prefix would give the child an open turn that the session constructor and the dev-mode [invariants](../../support/invariants) replay **reject**.
|
||||
|
||||
So the fork seeds only the **balanced completed-turn prefix** — the parent's log up to and including its last `turn/end`, excluding the in-flight turn entirely (`completedTurnPrefix`). Because the live log keeps `seq === index`, the slice is contiguous-from-0 and a valid seed. A parent on its very first (not-yet-complete) turn forks an *empty* seed — i.e. effectively a fresh child.
|
||||
|
||||
The seam this rides on: `CreateAgentOptions.seed` (added on `dsh-agent`, threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`), the same primitive `resume` uses.
|
||||
The delegating tool runs inside an open parent turn whose tool call has no result yet. Forking that tail would create an invalid, unbalanced child log, so the provider copies only the prefix through the last `turn/end`. A first-turn fork therefore starts with an empty seed. `CreateAgentOptions.seed` carries the contiguous prefix into session preparation.
|
||||
|
||||
## Capabilities
|
||||
|
||||
`{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }` — identical to spawn because the shared driver owns depth, model, persona, tool-filter, and structured-output behavior.
|
||||
`{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Meaning |
|
||||
|---|---|
|
||||
| `providerName` | Registry name on `ctx.subagents` (default `fork`). |
|
||||
|
||||
See [`dsh-subagent-spawn`](../subagent-spawn/README.md) for the run lifecycle, model inheritance, and depth tracking — all shared.
|
||||
|
||||
@@ -1,22 +1,8 @@
|
||||
/**
|
||||
* The in-process FORK subagent backend: registers a {@link SubagentProvider} on
|
||||
* `ctx.subagents` that runs each child as a child {@link Agent} SEEDED with a
|
||||
* prefix of the parent's session log — so the child inherits the parent's
|
||||
* conversation context instead of starting fresh. The run mechanics live in
|
||||
* `@deepseek-ai/dsh-subagent-inprocess` ({@link startInProcessRun}); this
|
||||
* backend just computes the seed. The spawn backend is an independent peer over
|
||||
* the same driver.
|
||||
*
|
||||
* The seed boundary is the crux: at the moment a subagent tool's `execute`
|
||||
* runs, the parent's CURRENT turn is open and unbalanced (it holds the
|
||||
* `assistant/message` with this spawn's tool-call, plus the dangling `tool/call`
|
||||
* with no `tool/result`). Seeding that raw prefix gives the child an open turn
|
||||
* the session constructor and the dev-mode invariants replay REJECT. So the
|
||||
* fork seeds only the **balanced completed-turn prefix**: the parent's log up
|
||||
* to and including its last `turn/end`, excluding the in-flight turn entirely.
|
||||
*
|
||||
* Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default.
|
||||
*
|
||||
* `ctx.subagents` that runs each child as a child {@link Agent} SEEDED with a prefix of the
|
||||
* parent's session log — so the child inherits the parent's conversation context instead of
|
||||
* starting fresh.
|
||||
* @module @deepseek-ai/dsh-subagent-fork
|
||||
*/
|
||||
|
||||
@@ -45,12 +31,9 @@ export const Config: z<Config> = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* The balanced completed-turn prefix of `parent`'s log: every event up to and
|
||||
* including the last `turn/end`. Empty if the parent has never completed a turn
|
||||
* (the in-flight turn is excluded, so a parent on its very first turn forks an
|
||||
* empty — i.e. fresh — child). The result is contiguous from seq 0 (the live
|
||||
* log keeps `seq === index`), so it is a valid session seed; the in-flight,
|
||||
* unbalanced turn is dropped so the invariants replay accepts it.
|
||||
* The balanced completed-turn prefix of `parent`'s log: every event up to and including the
|
||||
* last `turn/end`.
|
||||
*
|
||||
* @param parent - the agent whose session log to slice.
|
||||
* @returns the seed events, contiguous from seq 0; empty when no turn has completed.
|
||||
*/
|
||||
|
||||
@@ -132,10 +132,8 @@ describe('dsh-subagent-fork', () => {
|
||||
})
|
||||
|
||||
it('produces an invariant-CLEAN seed: forking mid-turn excludes the open turn', async () => {
|
||||
// Drive the parent so it has ONE completed turn, then start a SECOND turn
|
||||
// that is still open (a hanging model call), and fork while it's in flight.
|
||||
// The fork must seed only the completed first turn — an unbalanced seed
|
||||
// would make the invariants replay throw inside ctx.subagents.start.
|
||||
// Drive the parent so it has one completed turn, then start a SECOND turn that is still
|
||||
// open (a hanging model call), and fork while it's in flight.
|
||||
const { ctx, parent } = await setup([textResponse('done'), 'hang', textResponse('child')])
|
||||
parent.send([{ type: 'text', text: 'q1' }])
|
||||
await parent.whenIdle()
|
||||
@@ -180,12 +178,7 @@ describe('dsh-subagent-fork', () => {
|
||||
})
|
||||
|
||||
it('does NOT return the seeded parent output when the child produces no message of its own', async () => {
|
||||
// Regression: readResult must scope to the child's OWN events (after the
|
||||
// seed). The parent completes a turn with a distinctive assistant message,
|
||||
// then the fork child's own turn finishes with a bare `stop` and NO
|
||||
// assistant/message. Scanning the whole (seeded) log would return the
|
||||
// parent's "parent stale" message with stopReason 'completed'; scoped to the
|
||||
// child's own events the output is empty.
|
||||
// Regression: readResult must scope to the child's own events (after the seed).
|
||||
const { ctx, parent } = await setup([textResponse('parent stale'), emptyStop])
|
||||
parent.send([{ type: 'text', text: 'parent question' }])
|
||||
await parent.whenIdle()
|
||||
|
||||
@@ -1,39 +1,25 @@
|
||||
# @deepseek-ai/dsh-subagent-inprocess
|
||||
|
||||
The shared **in-process subagent run driver**. A library with no provider or import-time registration that the in-process backends — [spawn](../subagent-spawn/README.md) (a fresh child) and [fork](../subagent-fork/README.md) (a child seeded with a prefix of the parent's log) — both build on. Each accepted run installs one provider-owned cleanup effect. The backends are thin shells that differ ONLY in the session seed they pass; everything downstream lives here, so neither backend depends on the other.
|
||||
Shared run driver for the in-process [spawn](../subagent-spawn/README.md) and [fork](../subagent-fork/README.md) providers. It creates a child agent on the same Cordis application; the providers differ only in the optional session seed.
|
||||
|
||||
## What it exports
|
||||
## `startInProcessRun(ctx, request, options)`
|
||||
|
||||
### `startInProcessRun(ctx, request, options): SubagentRun`
|
||||
The driver snapshots mutable request data, checks delegation depth, and creates one run-owner fiber under the parent. Parent teardown, provider teardown, manual disposal, and cancellation during creation converge on that owner.
|
||||
|
||||
Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`):
|
||||
Child creation uses fresh IDs, lineage, an inherited or overridden model, and an unpublished setup callback for persona, tool restriction, and structured output. `run.started` resolves after the child is published. The result path sends one prompt, waits for idle, and derives output only from events after the seed boundary; a seeded parent answer cannot become the child's result.
|
||||
|
||||
1. snapshots the accepted request before asynchronous owner setup: the parent and signal remain identity capabilities but are never reread from the caller-owned record; tool filter, seed, agent options, output schema, and prompt are detached. It computes child depth = `depthOf(parent) + 1` and rejects `request.maxDepth` overflow with `SubagentDepthError`; `outputSchema` is asserted before cloning so a hostile value fails as `OutputSchemaError`, while the prompt passes the session log's lossless-JSON check before and after cloning;
|
||||
2. first installs provider ownership, then attaches the request abort listener and creates one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves no child or orphaned listener. Async child creation goes through that fiber's `ctx.agents` service with fresh IDs, lineage/seed, inherited model, and an unpublished setup transaction for persona, tool restriction, and structured output. Parent teardown, provider teardown, and manual `run.dispose()` all dispose this exact node, preventing publication after it becomes inactive and awaiting the same quiescence boundary. `startInProcessRun` still returns its `SubagentRun` immediately: `run.started` resolves only after `ctx.agents.create()` has published the child (and rejects if publication never happens), while cancellation during creation is recorded and applied when a child exists;
|
||||
3. drives the one-shot: `child.send(prompt)` then `await child.whenIdle()` (ordering matters — `send` enqueues synchronously, so `whenIdle` observes the queued work and resolves on the child's `running → idle` transition, never before the turn starts); there is deliberately NO re-prompt for a structured child that finished cleanly without calling `structured_output` — the shortfall maps to an `error` result for the parent;
|
||||
4. reads the result, scoped to the child's OWN events (everything at or after `seedLength`, so a seeded child that produced no message of its own never returns the seeded parent's last message): the last `assistant/message` content (deep-cloned — the log is frozen) and the last `turn/end.reason` mapped to a `SubagentStopReason`. A structured run surfaces the captured value as `result.structured`; a structured child that finished cleanly WITHOUT ever capturing settles `error` (a clean finish without the demanded result is a failure, not a success with a missing field).
|
||||
`dispose()` awaits creation or rollback and then the child handle's quiescent disposal. `cancel()` records pre-publication cancellation and applies it when the child exists. A cancelled attempt with no completed turn reports `aborted`.
|
||||
|
||||
`SubagentService` waits for `run.started` before emitting `subagent/start`, so a synchronous start observer can resolve the published child with `ctx.agents.get(run.id)`; the result driver awaits the same boundary before sending the prompt. An attempt that never publishes rejects readiness and emits no false start/end pair; its result reports a deliberate cancel/dispose as `aborted` and propagates an infrastructure fault. `dispose()` awaits creation or rollback and then delegates to `AgentHandle.dispose()` (stop and drain → remove agent → detach session → unwind scope); `cancel()` records its request even before publication and cancels the child immediately once available. A cancel landing before any `turn/end` still settles `aborted`, honoring the cancel contract rather than the generic no-turn `error`.
|
||||
`InProcessRunOptions` is `{ seed?: SessionEvent[] }`: absent for spawn and the completed-turn prefix for fork.
|
||||
|
||||
### `InProcessRunOptions`
|
||||
## Structured output
|
||||
|
||||
`{ seed?: SessionEvent[] }` — the optional child-session seed: absent for spawn, or the parent's balanced completed-turn prefix for fork.
|
||||
`attachStructuredRuntime(childCtx, schema)` installs a child-scoped capture tool, prompt instruction, protection, result observer, guard, and terminal turn policy. The actual schema is registered only for that child.
|
||||
|
||||
### Structured output (package-internal runtime)
|
||||
A validated value is staged by immutable execution identity and committed only after the authoritative `tools/result` succeeds. Code Mode also waits for the enclosing `run_code` result. Once pending or committed, later tool calls are denied; after commit, `agent/turn-stop` prevents another model step. A child that finishes without a committed value reports an error.
|
||||
|
||||
`attachStructuredRuntime(childCtx, schema)` registers the run's whole enforcement surface as SCOPED registrations on the child's `agent.ctx` — riding the child's fiber (a backend hot-reload mid-run cannot unregister anything; a disposed child leaves no residue) and visible to that child alone (two concurrent structured runs never interact; no placeholder schema, no strip-for-everyone-else, no refcounted global state):
|
||||
## Depth
|
||||
|
||||
- the `structured_output` capture tool with the run's REAL schema as its registered `parameters`, validating each call (`validateStructuredValue`) — violations become an `INVALID_ARGS` isError the model retries in-turn; a valid call STAGES the value in a `WeakMap` keyed by that call's `ToolExecution` object;
|
||||
- the calling instruction as an ordinary order-190 scoped prompt section (the demand travels with the tool, as prompt state of exactly one agent);
|
||||
- a scoped `systemPrompt.protect()` registration making the capture instruction and schema canonical after the complete assembly waterfall. Canonical absence is protected too: pure Code Mode removes `structured_output` from the wire and declares it through the SDK. The tool registry separately owns and protects its `tools:sdk` section and reserved `run_code` transport; protection guarantees those named contributions, while unrelated listener-added schemas remain the listener's responsibility. The loop logs the finalized assembly as the step's `request/header`, so the demand remains reconstructable;
|
||||
- a scoped `tools/result` observer as the commit point: it promotes a staged value only when that same execution's immutable, JSON-safe authoritative result after the complete pre-execute → guards → execute → post-execute pipeline succeeds. For a Code Mode SDK sub-dispatch, the child's opaque `parent` token matches the enclosing `run_code` execution's registry-assigned `token`, so promotion waits for that outer final result without exposing its live object; a runtime failure or post-policy block discards the value. Execution-object identity prevents call-id reuse or another execution from reaching the stage;
|
||||
- a scoped monotonic `tools.guard()` denial for every call arriving after capture. Guards run after the extensible pre-execute waterfall and cannot return allow, so terminal means terminal within the step regardless of listener order;
|
||||
- a scoped `agent/turn-stop` terminal policy stopping the child's turn once its output is captured. It runs after ordinary continuation and steering folding, and its terminal state survives turn close and flush, so later listeners cannot leak steering into another step or turn; ordinary queued prompts remain intact.
|
||||
`depthOf(agent)` reads merge-extensible `AgentOptions.subagentDepth` (default `0`). `startInProcessRun` throws `SubagentDepthError` when the next depth exceeds `maxDepth`.
|
||||
|
||||
### `depthOf(agent): number`
|
||||
|
||||
Delegation depth rides on a merge-extensible `AgentOptions.subagentDepth` field (0 for a top-level agent, parent + 1 for a child), so a nested spawn reads its parent's depth from `parent.options.subagentDepth`. `depthOf` reads it (absent ⇒ 0).
|
||||
|
||||
### `SubagentDepthError`
|
||||
|
||||
Thrown by `startInProcessRun` when a spawn would exceed the request's `maxDepth` cap; carries `attemptedDepth` and `maxDepth`.
|
||||
See [the agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md) for ownership and final-policy rationale.
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
/**
|
||||
* The shared in-process subagent run driver: run a child as a child
|
||||
* {@link Agent} on the SAME cordis context (`ctx.agents`) — the cheapest
|
||||
* transport, reusing the agent factory's quiescent {@link AgentHandle}
|
||||
* teardown. The concrete in-process backends are thin shells over this driver,
|
||||
* differing ONLY in the `seed` they pass (a fresh child vs. a child seeded with
|
||||
* a prefix of the parent's log); everything downstream — drive the child, read
|
||||
* its final output, map the stop reason, dispose — is identical and lives here.
|
||||
*
|
||||
* This package declares no provider and performs no import-time registration;
|
||||
* it is a library the backend packages depend on, so neither backend needs to
|
||||
* know about the other. Each accepted run does install one provider-owned
|
||||
* effect for structured-concurrency cleanup.
|
||||
*
|
||||
* The shared in-process subagent run driver: run a child as a child {@link Agent} on the same
|
||||
* cordis context (`ctx.agents`) — the cheapest transport, reusing the agent factory's
|
||||
* quiescent {@link AgentHandle} teardown.
|
||||
* @module @deepseek-ai/dsh-subagent-inprocess
|
||||
*/
|
||||
|
||||
@@ -75,9 +65,8 @@ function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason {
|
||||
return 'max-tokens'
|
||||
case 'aborted':
|
||||
return 'aborted'
|
||||
// `disposed` (torn down mid-turn) and `interrupted` (crash-closed) both mean
|
||||
// the turn did not finish cleanly; surface them as a generic failure rather
|
||||
// than a clean completion. A missing reason (no turn ran) is also an error.
|
||||
// `disposed` (torn down mid-turn) and `interrupted` (crash-closed) both mean the turn did
|
||||
// not finish cleanly; surface them as a generic failure rather than a clean completion.
|
||||
case 'error':
|
||||
case 'disposed':
|
||||
case 'interrupted':
|
||||
@@ -104,16 +93,6 @@ async function quiesceFiber(fiber: Fiber): Promise<void> {
|
||||
/**
|
||||
* Start an in-process child agent for `request` and return a {@link SubagentRun}.
|
||||
*
|
||||
* Drives the child as a one-shot: `send(prompt)` then `whenIdle()` (the ordering
|
||||
* matters — `send` enqueues synchronously, so `whenIdle` observes the queued
|
||||
* work and resolves only on the child's `running → idle` transition, never
|
||||
* before the turn starts). The final `assistant/message` is the result output,
|
||||
* the matching `turn/end.reason` the stop reason. `dispose()` delegates to the
|
||||
* factory's {@link AgentHandle.dispose} (stop loop → await quiescence → remove
|
||||
* session); `cancel()` cancels the child's in-flight turn.
|
||||
*
|
||||
* Throws {@link SubagentDepthError} before creating anything when the child's
|
||||
* depth (parent depth + 1) would exceed `request.maxDepth`.
|
||||
* @param ctx - the provider context that owns the live run as a second
|
||||
* structured-concurrency boundary alongside the parent agent.
|
||||
* @param request - the start request (prompt, parent, signal, per-child options).
|
||||
@@ -137,22 +116,11 @@ export function startInProcessRun(
|
||||
if (request.maxDepth !== undefined && childDepth > request.maxDepth) {
|
||||
throw new SubagentDepthError(childDepth, request.maxDepth)
|
||||
}
|
||||
// Assert, then snapshot, the schema subset BEFORE any child exists (the
|
||||
// service has already capability-gated; this rejects a schema outside the
|
||||
// enforced subset loud). Assertion comes FIRST so a hostile value fails as
|
||||
// OutputSchemaError, never as structuredClone's raw DataCloneError — the
|
||||
// asserted subset is plain JSON data, which always clones. The snapshot is
|
||||
// load-bearing: the caller keeps its reference, so attaching the ORIGINAL
|
||||
// would let a post-start() mutation drift the enforced schema away from the
|
||||
// asserted one — the clone (taken synchronously with the assertion, no
|
||||
// interleaving possible) pins assertion, the model-visible parameters, and
|
||||
// validateStructuredValue to one isolation-immutable value.
|
||||
// Assert, then snapshot, the schema subset before any child exists (the service has already
|
||||
// capability-gated; this rejects a schema outside the enforced subset loud).
|
||||
if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema)
|
||||
const schema = request.outputSchema === undefined ? undefined : structuredClone(request.outputSchema)
|
||||
// The accepted request owns a value snapshot, not the caller's mutable
|
||||
// content array. Validate the same lossless-JSON contract Session.append
|
||||
// enforces before any child exists, then detach it synchronously so mutation
|
||||
// during async creation cannot change what is logged or sent to the model.
|
||||
// The accepted request owns a value snapshot, not the caller's mutable content array.
|
||||
if (!isJsonValue(request.prompt)) {
|
||||
throw new TypeError('subagent prompt must be losslessly JSON-serializable')
|
||||
}
|
||||
@@ -168,24 +136,15 @@ export function startInProcessRun(
|
||||
// SEEDED parent's last assistant message as its result.
|
||||
const seedLength = options.seed?.length ?? 0
|
||||
const parentHeader = parent.session.header
|
||||
// Inherit the parent's model by default (a child with no model cannot run);
|
||||
// an explicit `request.agentOptions.model` overrides it. The deployment
|
||||
// persona needs no inheritance (a context-wide section both render); a
|
||||
// per-child `request.persona` becomes a SCOPED section of the same name in
|
||||
// the setup below, shadowing the deployment's for this child alone.
|
||||
// Inherit the parent's model by default (a child with no model cannot run); an explicit
|
||||
// `request.agentOptions.model` overrides it.
|
||||
const agentOptions: AgentOptions = structuredClone({
|
||||
...parent.options.model !== undefined ? { model: parent.options.model } : {},
|
||||
...request.agentOptions,
|
||||
subagentDepth: childDepth,
|
||||
})
|
||||
|
||||
// The child's scoped world, composed in the factory's unpublished setup
|
||||
// window. The factory awaits it before inserting or announcing the child, so
|
||||
// a throw/rejection exposes neither id and every first assembly sees it:
|
||||
// - persona: a scoped `deployment:persona` section shadowing the global one;
|
||||
// - toolFilter: a scoped restrict() masking the global tool surface
|
||||
// (loud unknown-name validation lives in the registry);
|
||||
// - outputSchema: the structured runtime, attached as scoped registrations.
|
||||
// The child's scoped world, composed in the factory's unpublished setup window.
|
||||
let structured: StructuredAttachment | undefined
|
||||
const setup = (childCtx: Context): void => {
|
||||
if (persona !== undefined) {
|
||||
@@ -199,15 +158,8 @@ export function startInProcessRun(
|
||||
}
|
||||
}
|
||||
|
||||
// Bridge the request's abort signal to the child (the consumer also bridges
|
||||
// its own exec.signal, but a backend-level bridge keeps the contract local).
|
||||
// Install it after provider ownership succeeds but BEFORE awaiting creation,
|
||||
// so an inactive provider cannot leave an orphaned listener and abort/dispose
|
||||
// during async setup is still recorded and applied the moment a child exists.
|
||||
// `cancelled` records that a cancel was requested at all, so the pre-turn
|
||||
// cancel window — where the child clears the queued prompt before any
|
||||
// `turn/end` is logged — settles as `aborted` (honoring the cancel contract)
|
||||
// rather than falling through to the no-turn `error` mapping.
|
||||
// Bridge the request's abort signal to the child (the consumer also bridges its own
|
||||
// exec.signal, but a backend-level bridge keeps the contract local).
|
||||
let cancelled = false
|
||||
// An accessor, not an inline read: `cancelled` mutates from closures (the
|
||||
// abort listener, run.cancel), which control-flow narrowing cannot see — an
|
||||
@@ -223,13 +175,7 @@ export function startInProcessRun(
|
||||
}
|
||||
const onAbort = (): void => { requestCancel('subagent cancelled') }
|
||||
|
||||
// One run-owned Cordis fiber is the common ownership node. Install the
|
||||
// provider effect FIRST: a start racing an already-unloading provider fails
|
||||
// before it can mint anything under the parent. The owner fiber is then
|
||||
// nested under the parent scope, and the provider/run handle both dispose
|
||||
// this exact fiber. AgentFactory binds its lifecycle to `ownerCtx`, so any of
|
||||
// the three owners moves the fiber out of ACTIVE synchronously and setup
|
||||
// cannot publish afterward.
|
||||
// One run-owned Cordis fiber is the common ownership node.
|
||||
let ownerCtx: Context | undefined
|
||||
function subagentRunOwner(inner: Context): void { ownerCtx = inner }
|
||||
let ownerFiber: (Fiber & PromiseLike<Fiber>) | undefined
|
||||
@@ -264,12 +210,7 @@ export function startInProcessRun(
|
||||
if (ownerCtx === undefined) {
|
||||
throw new Error('subagent run owner became inactive before child creation')
|
||||
}
|
||||
// Invoke the factory THROUGH the parent scope. Cordis binds the factory's
|
||||
// lifecycle effect to the accessing context, so parent ownership exists
|
||||
// before persistence/setup and publication—not as a fallible link added
|
||||
// after the child is already visible. A disposed parent therefore rejects
|
||||
// before any session/agent notification, and disposal during async setup
|
||||
// wins the unpublished transaction.
|
||||
// Invoke the factory THROUGH the parent scope.
|
||||
const created = await ownerCtx.agents.create({
|
||||
agentId: childId,
|
||||
sessionId: SessionId(randomUUID()),
|
||||
@@ -289,12 +230,7 @@ export function startInProcessRun(
|
||||
return created.agent
|
||||
})()
|
||||
|
||||
// Provider readiness is a distinct lifecycle boundary from accepting the
|
||||
// request. It resolves only after the factory has published the child and
|
||||
// returned its handle, so SubagentService can emit `subagent/start` while
|
||||
// `ctx.agents.get(childId)` is guaranteed to resolve. The result path awaits
|
||||
// THIS SAME promise immediately, which also observes a readiness rejection
|
||||
// when the driver is invoked directly rather than through SubagentService.
|
||||
// Provider readiness is a distinct lifecycle boundary from accepting the request.
|
||||
const started: Promise<void> = creation.then(() => undefined)
|
||||
|
||||
const result: Promise<SubagentResult> = (async () => {
|
||||
@@ -356,22 +292,10 @@ export function startInProcessRun(
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a settled child's terminal result from its session log, scoped to the
|
||||
* child's OWN events (everything at or after `seedLength` — fork seeds the
|
||||
* parent's completed-turn prefix, so a child that produced no message of its
|
||||
* own must NOT return the seeded parent's last assistant message). The output
|
||||
* is the child's last `assistant/message` content (deep-cloned — the log is
|
||||
* frozen); the stop reason is the child's last `turn/end` reason mapped to a
|
||||
* {@link SubagentStopReason}. When `cancelled` is set but no `turn/end` was
|
||||
* logged (a cancel landed in the pre-turn window, before any turn ran), the
|
||||
* run settles `aborted` per the {@link SubagentRun.cancel} contract rather than
|
||||
* the generic no-turn `error`.
|
||||
*
|
||||
* A structured run (`structured` present) additionally reports the captured
|
||||
* value on {@link SubagentResult.structured}. A structured child that finished
|
||||
* CLEANLY without ever capturing (the nudges ran out) settles `error` — a clean
|
||||
* finish without the demanded structured result is a failure, not a success
|
||||
* with a missing field; a non-`completed` reason keeps its own honest mapping.
|
||||
* Read a settled child's terminal result from its session log, scoped to the child's own
|
||||
* events (everything at or after `seedLength` — fork seeds the parent's completed-turn prefix,
|
||||
* so a child that produced no message of its own must not return the seeded parent's last
|
||||
* assistant message).
|
||||
*/
|
||||
function readResult(
|
||||
child: Agent,
|
||||
|
||||
@@ -1,45 +1,6 @@
|
||||
/**
|
||||
* Structured-output support for the in-process subagent backends: the
|
||||
* mechanism behind `SubagentStartRequest.outputSchema` for children that run
|
||||
* as agents on the same context.
|
||||
*
|
||||
* Everything is a SCOPED registration on the child agent's context
|
||||
* (`child.ctx`, the dsh-scope seam): the `structured_output` capture tool
|
||||
* carries the run's REAL schema as its registered parameters (each child sees
|
||||
* exactly its own schema — two concurrent structured runs never interact), the
|
||||
* demand instruction is an ordinary order-190 scoped section, and the
|
||||
* enforcement listeners fire only for this child (scope-filtered dispatch).
|
||||
* Registration lifetime rides the child's fiber, so a backend hot-reload
|
||||
* mid-run cannot unregister the capture tool out from under a live child, and
|
||||
* a disposed child leaves no residue — no placeholder schema,
|
||||
* strip-for-everyone-else pass, or refcounted global runtime.
|
||||
*
|
||||
* The child scope's registrations enforce the contract:
|
||||
*
|
||||
* - `systemPrompt.protect()` declaratively protects the capture tool and its
|
||||
* instruction. The service restores their canonical pre-waterfall state
|
||||
* after EVERY assembly listener. Canonical absence is protected too: pure
|
||||
* Code Mode keeps `structured_output` in the SDK only and never grows a
|
||||
* second native wire tool. Code Mode's owner independently protects its SDK
|
||||
* and `run_code` transport. The loop logs the finalized assembly as the
|
||||
* request header, so the demand is reconstructable log state, never a
|
||||
* wire-only mutation.
|
||||
* - `agent/turn-stop` (serial, scoped): stop the child's turn once its output
|
||||
* is captured. This terminal checkpoint runs after the ordinary continuation
|
||||
* waterfall and steering folding, so listener order cannot resurrect a
|
||||
* completed structured run or carry terminal steering into another turn.
|
||||
* - `tools.guard()` is the monotonic terminal gate after the extensible
|
||||
* pre-execute waterfall: once capture commits, no later listener can turn
|
||||
* the denial back into a dispatched side effect.
|
||||
* - `tools/result` is the capture COMMIT point. The tool body only STAGES the
|
||||
* validated value in a WeakMap keyed by the execution object; the awaited,
|
||||
* non-transforming notification promotes it only when the authoritative
|
||||
* result after the whole pre/execute/post pipeline succeeds. For a Code Mode
|
||||
* sub-dispatch, promotion waits again for the enclosing `run_code` result, so
|
||||
* a runtime failure or outer post-policy block cannot report structured
|
||||
* success. Execution identity makes call-id reuse and orphaned stages
|
||||
* irrelevant.
|
||||
*
|
||||
* Child-scoped structured-output capture. Values commit only after the final
|
||||
* tool outcome; guards and terminal turn policy prevent work after capture.
|
||||
* @module @deepseek-ai/dsh-subagent-inprocess/structured
|
||||
*/
|
||||
|
||||
@@ -52,11 +13,7 @@ import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } f
|
||||
/** The model-facing tool name a structured child must call to finish. */
|
||||
export const STRUCTURED_OUTPUT_TOOL = 'structured_output'
|
||||
|
||||
/**
|
||||
* The instruction registered as the child's trailing (order-190, the end of
|
||||
* the tool-guidance band) scoped prompt section: the demand travels with the
|
||||
* tool, as ordinary prompt state of exactly one agent.
|
||||
*/
|
||||
/** Prompt instruction paired with the child-scoped capture tool. */
|
||||
export const STRUCTURED_OUTPUT_INSTRUCTION
|
||||
= 'When you have your final answer, you MUST report it by calling the '
|
||||
+ `\`${STRUCTURED_OUTPUT_TOOL}\` tool with arguments matching its parameter schema exactly. `
|
||||
@@ -64,35 +21,18 @@ export const STRUCTURED_OUTPUT_INSTRUCTION
|
||||
|
||||
/** One structured run's live handle: read the captured value once the child settles. */
|
||||
export interface StructuredAttachment {
|
||||
/**
|
||||
* The captured value, once the child called the tool with valid arguments
|
||||
* and the authoritative final tool result accepted that call.
|
||||
* @returns the committed value, or undefined while none was accepted.
|
||||
*/
|
||||
/** @returns the committed value, or `undefined` until one is accepted. */
|
||||
captured(): { value: unknown } | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the structured-output runtime to a child for `schema`: register the
|
||||
* scoped capture tool (real schema), the scoped instruction section, and the
|
||||
* scoped enforcement registrations (see the module doc). Call from the
|
||||
* agent-creation `setup` window with the child's scope context — every
|
||||
* registration rides the child's fiber and unwinds with the child.
|
||||
* @param childCtx - the child agent's scope context (`setup`'s argument).
|
||||
* @param schema - the isolation-cloned, already-asserted schema subset to
|
||||
* enforce (see `assertSupportedOutputSchema` in dsh-tools).
|
||||
* @returns the attachment handle (read `captured()` after the child settles).
|
||||
* Install structured-output capture in a child's setup scope.
|
||||
* @param childCtx - child agent scope context.
|
||||
* @param schema - validated schema enforced by the capture tool.
|
||||
* @returns handle for reading the committed value after settlement.
|
||||
*/
|
||||
export function attachStructuredRuntime(childCtx: Context, schema: StructuredOutputSchema): StructuredAttachment {
|
||||
/**
|
||||
* Validated values staged by the capture tool body, awaiting THEIR OWN
|
||||
* authoritative `tools/result` notification. The execution object's identity
|
||||
* uniquely identifies a trip through the pipeline: adapter call ids may
|
||||
* repeat across steps, but another execution can never reach this WeakMap
|
||||
* entry. This is distinct from the opaque `ToolExecutionToken` used to
|
||||
* correlate nested transports. The final notification always deletes its own
|
||||
* stage, whether the result succeeded or failed.
|
||||
*/
|
||||
// Stages are keyed by pipeline identity, not reusable model call ids.
|
||||
const staged = new WeakMap<ToolExecution, { value: unknown }>()
|
||||
/** Successful nested capture waiting for its enclosing transport to commit. */
|
||||
let pending: { parent: ToolExecution['token']; value: unknown } | undefined
|
||||
@@ -103,8 +43,7 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut
|
||||
description:
|
||||
'Report your final structured result. Call this exactly once, when your answer is complete; '
|
||||
+ 'the arguments must match this tool\'s parameter schema exactly.',
|
||||
// ToolSchema.parameters is the wire-level JSON Schema object; the
|
||||
// asserted subset type is structurally exactly that.
|
||||
// The validated subset is a wire-level JSON Schema object.
|
||||
parameters: schema as unknown as Record<string, unknown>,
|
||||
}
|
||||
|
||||
@@ -112,12 +51,8 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut
|
||||
...schemaEntry,
|
||||
execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]> {
|
||||
const violations = validateStructuredValue(schema, args)
|
||||
// ToolArgsError → isError result with INVALID_ARGS: the model retries
|
||||
// within the same turn, exactly like a schema-validated defineTool call.
|
||||
if (violations.length > 0) throw new ToolArgsError(violations)
|
||||
// Two-phase commit, keyed by THIS execution: later transformable
|
||||
// waterfalls may still turn the success into an error. Snapshot the
|
||||
// validated value independently of the already-frozen pipeline arguments.
|
||||
// Commit waits for this execution's final result.
|
||||
staged.set(exec, { value: structuredClone(args) })
|
||||
return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }])
|
||||
},
|
||||
@@ -129,34 +64,21 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut
|
||||
text: STRUCTURED_OUTPUT_INSTRUCTION,
|
||||
})
|
||||
|
||||
// Service-owned finalization, not waterfall ordering. The canonical
|
||||
// assembly determines both presence and absence: native/both modes restore
|
||||
// the capture schema on the wire, while pure Code Mode removes any injected
|
||||
// native entry. ToolRegistry's own protection independently restores the SDK
|
||||
// section and run_code transport that carry the same schema.
|
||||
// Protection preserves the mode-appropriate canonical presence or absence.
|
||||
childCtx.systemPrompt.protect({
|
||||
sections: [`tool:${STRUCTURED_OUTPUT_TOOL}`],
|
||||
tools: [STRUCTURED_OUTPUT_TOOL],
|
||||
})
|
||||
|
||||
// Stop the child's turn once its output is captured. This monotonic serial
|
||||
// checkpoint runs after the ordinary continuation waterfall, its reason,
|
||||
// and late-steering folding, so no ordering trick can resume a finished run.
|
||||
childCtx.on('agent/turn-stop', function (this: unknown): ContinuationStop | undefined {
|
||||
return captured === undefined ? undefined : { action: 'stop' }
|
||||
})
|
||||
|
||||
// Terminal WITHIN the step. Guards run after the whole pre-execute
|
||||
// waterfall and compose monotonically (deny or abstain, never allow), so a
|
||||
// later prepended listener cannot resurrect dispatch. Calls that precede
|
||||
// capture in the same response remain untouched.
|
||||
// Calls earlier in the same response remain valid; later calls are terminally denied.
|
||||
childCtx.tools.guard(exec => captured === undefined && pending === undefined
|
||||
? undefined
|
||||
: `structured output already recorded: the run is complete, so \`${exec.name}\` is not executed`)
|
||||
|
||||
// The capture COMMIT observes the immutable, authoritative result after the
|
||||
// complete pipeline and outer error normalization. This notification cannot
|
||||
// transform the outcome, so there is no wrapper outside the commit verdict.
|
||||
childCtx.on('tools/result', function (this: unknown, exec, result): void {
|
||||
if (exec.name === STRUCTURED_OUTPUT_TOOL) {
|
||||
const entry = staged.get(exec)
|
||||
|
||||
@@ -236,11 +236,7 @@ describe('in-process structured output', () => {
|
||||
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'stop' }))
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
let wrapperInstalled = false
|
||||
// Register this observer only after start() returns. The child session-start
|
||||
// boundary is after its unpublished setup attached structured output but
|
||||
// before the loop can run; install a prepended wrapper there. It awaits the
|
||||
// explicit downstream stop above, then overwrites that result with continue.
|
||||
// The later terminal checkpoint still wins.
|
||||
// Register this observer only after start() returns.
|
||||
ctx.on('agent/session-start', (child) => {
|
||||
if (child.id !== run.id) return
|
||||
wrapperInstalled = true
|
||||
@@ -263,10 +259,7 @@ describe('in-process structured output', () => {
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 9 }),
|
||||
textResponse('MUST NOT BE CONSUMED'),
|
||||
])
|
||||
// The downstream ordinary policy says stop. A wrapper registered after
|
||||
// start() delegates to that stop, then queues steering; ordinary folding
|
||||
// would turn the stop back into continue. The terminal checkpoint runs
|
||||
// afterwards and discards that steering.
|
||||
// The downstream ordinary policy says stop.
|
||||
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'stop' }))
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
ctx.on('agent/session-start', (child) => {
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
# @deepseek-ai/dsh-subagent-spawn
|
||||
|
||||
The in-process **spawn** subagent backend: a [`SubagentProvider`](../subagent/README.md) that runs each child as a **fresh** child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`) — its own session, its own (or the parent's) model, zero inherited conversation. The cheapest transport, reusing the agent factory's quiescent [`AgentHandle`](../../core/agent) teardown.
|
||||
In-process provider that runs each request as a fresh child [`Agent`](../../core/agent) on the same Cordis application. The child has a new session and no inherited conversation; it uses the parent model unless overridden.
|
||||
|
||||
The run mechanics live in the shared [`@deepseek-ai/dsh-subagent-inprocess`](../subagent-inprocess/README.md) driver (`startInProcessRun`); this backend just passes **no seed** (a fresh child). The [fork](../subagent-fork/README.md) backend is an independent peer over the same driver — neither knows about the other.
|
||||
|
||||
## What it does
|
||||
|
||||
`start(request)` delegates to `startInProcessRun(ctx, request, {})` with no seed: a fresh child agent with the parent's `cwd`/`parentSession` lineage and (by default) the parent's model. The driver creates one run-owner fiber under `parent.ctx`; parent teardown, this provider's teardown, and manual disposal all converge there before child publication. Its `run.started` boundary resolves only after the fresh child is published, so `subagent/start` observers see a live registry entry. See the [driver README](../subagent-inprocess/README.md) for the full lifecycle (depth check, one-shot drive, result read, dispose).
|
||||
The package delegates lifecycle work to [`startInProcessRun`](../subagent-inprocess/README.md) with no seed. Child creation, persona, tool filtering, structured output, cancellation, and quiescent disposal are owned by the shared driver. `run.started` resolves only after publication, so `subagent/start` observers can resolve the child from `ctx.agents`.
|
||||
|
||||
## Capabilities
|
||||
|
||||
`{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`. It constructs the child, so it enforces a recursion cap and composes the child's persona, global-tool restriction, and [structured runtime](../subagent-inprocess/README.md) inside the agent-creation setup window. At apply it registers this one named provider on `ctx.subagents`; per-run contributions belong to each child's scope.
|
||||
`{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`
|
||||
|
||||
## Config
|
||||
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
/**
|
||||
* The in-process SPAWN subagent backend: registers a {@link SubagentProvider}
|
||||
* on `ctx.subagents` that runs each child as a FRESH child {@link Agent} on the
|
||||
* same cordis context (its own session, own system prompt, zero parent
|
||||
* context). The cheapest transport, reusing the agent factory's quiescent
|
||||
* teardown.
|
||||
*
|
||||
* The run mechanics live in `@deepseek-ai/dsh-subagent-inprocess`
|
||||
* ({@link startInProcessRun}); this backend just passes NO seed (a fresh
|
||||
* child). The fork backend is an independent peer over the same driver.
|
||||
*
|
||||
* Structured output (`outputSchema`) is supported through the driver's
|
||||
* per-child scoped runtime: the child registers its real-schema capture tool,
|
||||
* prompt instruction, and enforcement listeners inside the creation setup
|
||||
* window, and its scope owns their lifetime.
|
||||
*
|
||||
* Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default.
|
||||
*
|
||||
* The in-process SPAWN subagent backend: registers a {@link SubagentProvider} on
|
||||
* `ctx.subagents` that runs each child as a fresh child {@link Agent} on the same cordis
|
||||
* context (its own session, own system prompt, zero parent context). The cheapest transport,
|
||||
* reusing the agent factory's quiescent teardown.
|
||||
* @module @deepseek-ai/dsh-subagent-spawn
|
||||
*/
|
||||
|
||||
@@ -25,10 +12,8 @@ import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } fro
|
||||
import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess'
|
||||
|
||||
export const name = 'subagent-spawn'
|
||||
// `tools` is deliberately NOT injected: the shared driver registers structured
|
||||
// output through the child's creation context, whose factory already requires
|
||||
// the tool service. Keeping it out of this backend's inject list preserves the
|
||||
// provider's independent apply timing.
|
||||
// `tools` is deliberately not injected: the shared driver registers structured output through
|
||||
// the child's creation context, whose factory already requires the tool service.
|
||||
export const inject = ['subagents']
|
||||
|
||||
/** Config: the registry name to register the provider under. */
|
||||
|
||||
@@ -148,11 +148,8 @@ describe('dsh-subagent-spawn', () => {
|
||||
})
|
||||
|
||||
it('settles aborted (without running the child) when the request signal is ALREADY aborted', async () => {
|
||||
// Regression: a signal aborted BEFORE the run starts never fires an `abort`
|
||||
// event, so the listener can't catch it. The driver must check the
|
||||
// already-aborted case up front and settle `aborted` without running the
|
||||
// child — otherwise an already-cancelled request runs to `completed`. The
|
||||
// empty script proves the child's model is never called.
|
||||
// Regression: a signal aborted before the run starts never fires an `abort` event, so the
|
||||
// listener can't catch it.
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const { ctx, parent } = await setup([])
|
||||
@@ -164,12 +161,8 @@ describe('dsh-subagent-spawn', () => {
|
||||
})
|
||||
|
||||
it('cancelling BEFORE the child turn starts settles aborted, not error', async () => {
|
||||
// Regression: a cancel landing in the pre-turn window clears the queued
|
||||
// prompt before any `turn/end` is logged. Deriving the stop reason from
|
||||
// `turn/end` alone then mis-maps the no-turn case to `error`; the run must
|
||||
// honor the cancel contract and settle `aborted`. The cancel is synchronous
|
||||
// (same tick as start, before the loop's queued-wait continuation runs), so
|
||||
// the turn is dropped and the empty script is never consumed.
|
||||
// Regression: a cancel landing in the pre-turn window clears the queued prompt before any
|
||||
// `turn/end` is logged.
|
||||
const { ctx, parent } = await setup([])
|
||||
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
|
||||
run.cancel('early')
|
||||
@@ -345,9 +338,7 @@ describe('dsh-subagent-spawn', () => {
|
||||
parent,
|
||||
outputSchema: { type: 'object', properties: { a: { type: 'number' } } },
|
||||
})
|
||||
// Let the child's step start streaming, then unload the backend. The
|
||||
// backend owns the child agent, so the unload tears the child down and
|
||||
// the run settles — releasing its own runtime acquisition on the way out.
|
||||
// Let the child's step start streaming, then unload the backend.
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
await fiber.dispose()
|
||||
const result = await run.result
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
/**
|
||||
* Shared machinery for OUT-OF-PROCESS subagent backends — providers that spawn
|
||||
* an external agent as a child process and must keep the parent deployment's
|
||||
* credentials out of it, tear it down to quiescence, and isolate it from the
|
||||
* host user's on-disk CLI state. The pieces: the credential env scrub
|
||||
* ({@link SENSITIVE_ENV_PATTERN} / {@link buildChildEnv}), the spawn-failure
|
||||
* capture ({@link spawnFailure}), the child-exit waits ({@link waitForExit} /
|
||||
* {@link exitsWithin}), the stdin-EOF → SIGTERM → SIGKILL dispose ladder
|
||||
* ({@link disposeChildProcess}), and the per-run isolated config dir
|
||||
* ({@link createIsolatedConfigDir}).
|
||||
*
|
||||
* This package owns no provider and registers nothing; it is a pure library
|
||||
* the out-of-process backend packages depend on (the `subagent-inprocess`
|
||||
* shape, for the process boundary). Every tunable — the ladder's grace
|
||||
* periods, a pinned config dir — is a PARAMETER here: defaults belong in each
|
||||
* consuming plugin's Config, per the no-hardcoded-tunables rule.
|
||||
*
|
||||
* Shared machinery for OUT-OF-PROCESS subagent backends — providers that spawn an external
|
||||
* agent as a child process and must keep the parent deployment's credentials out of it, tear
|
||||
* it down to quiescence, and isolate it from the host user's on-disk CLI state.
|
||||
* @module @deepseek-ai/dsh-subagent-subprocess
|
||||
*/
|
||||
|
||||
@@ -52,11 +39,8 @@ export function buildChildEnv(extra: Record<string, string>): NodeJS.ProcessEnv
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the child's spawn-level failure as a promise the run's result path
|
||||
* can race. A spawn failure (e.g. `ENOENT` for a bad command) is emitted as an
|
||||
* `error` EVENT, not a thrown exception — and without a listener Node treats
|
||||
* it as an unhandled error and crashes the parent process. Call this in the
|
||||
* SAME TICK as `spawn()`, so no window exists for the event to fire unheard.
|
||||
* Capture the child's spawn-level failure as a promise the run's result path can race.
|
||||
*
|
||||
* @param child - the just-spawned child process.
|
||||
* @returns a promise that RESOLVES (never rejects) with the child's first
|
||||
* `error` event; for a child that spawns cleanly it never settles.
|
||||
@@ -125,15 +109,8 @@ export interface DisposeLadderGraces {
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear a child process down to QUIESCENCE: resolves only once the child has
|
||||
* actually exited (or was already gone), never merely after requesting it.
|
||||
* Three-tier escalation —
|
||||
*
|
||||
* 1. stdin EOF (when stdin is piped), then wait `disposeEofGraceMs`: a
|
||||
* cooperative child quiesces on its own, its teardown and flushes intact;
|
||||
* 2. `SIGTERM`, then wait `disposeGraceMs`;
|
||||
* 3. `SIGKILL`, then await the (now-certain) exit — a child that ignores EOF
|
||||
* and traps `SIGTERM` must not wedge dispose forever.
|
||||
* Tear a child process down to QUIESCENCE: resolves only once the child has actually exited
|
||||
* (or was already gone), never merely after requesting it. Three-tier escalation —
|
||||
*
|
||||
* @param child - the child process to tear down.
|
||||
* @param graces - the two grace periods, from the consuming plugin's Config.
|
||||
@@ -141,10 +118,7 @@ export interface DisposeLadderGraces {
|
||||
export async function disposeChildProcess(child: ChildProcess, graces: DisposeLadderGraces): Promise<void> {
|
||||
// Already gone: nothing to reap.
|
||||
if (child.exitCode !== null || child.signalCode !== null) return
|
||||
// 1. Graceful: end the request stream (stdin EOF) and let the child quiesce
|
||||
// on its own. Sending SIGTERM in the same tick (or too soon) would
|
||||
// default-terminate a cooperative child mid-flush, orphaning its nested
|
||||
// work. A child spawned without a stdin pipe skips straight to the wait.
|
||||
// 1.
|
||||
child.stdin?.end()
|
||||
if (await exitsWithin(child, graces.disposeEofGraceMs)) return
|
||||
// 2. SIGTERM, escalating if the child still does not exit within the grace.
|
||||
@@ -173,16 +147,9 @@ export interface IsolatedConfigDir {
|
||||
}
|
||||
|
||||
/**
|
||||
* An isolated config dir for one child run, so the child's behavior is a
|
||||
* function of deployment config alone — never of whatever `~/.claude` /
|
||||
* `~/.codex`-style state happens to exist on the host machine. Two modes:
|
||||
*
|
||||
* - no `pinnedPath` (the default): creates a FRESH private (0700) `mkdtemp`
|
||||
* dir under the OS temp root; {@link IsolatedConfigDir.remove} deletes it
|
||||
* best-effort;
|
||||
* - `pinnedPath` set (a deployment deliberately sharing state across runs):
|
||||
* the pinned path is returned as-is — never created, never removed — the
|
||||
* deployment owns that directory's lifecycle.
|
||||
* An isolated config dir for one child run, so the child's behavior is a function of
|
||||
* deployment config alone — never of whatever `~/.claude` / `~/.codex`-style state happens to
|
||||
* exist on the host machine. Two modes.
|
||||
*
|
||||
* @param prefix - the `mkdtemp` name prefix for a fresh dir (e.g.
|
||||
* `dsh-subagent-codex-`); ignored when `pinnedPath` is set.
|
||||
@@ -209,10 +176,8 @@ export async function createIsolatedConfigDir(prefix: string, pinnedPath?: strin
|
||||
try {
|
||||
await rm(path, { recursive: true, force: true })
|
||||
} catch {
|
||||
// Best-effort by contract: swallows rm failures (EACCES/EBUSY-style —
|
||||
// e.g. the dead child left an unreadable entry behind). The dir lives
|
||||
// under the OS temp root, which reclaims it; failing dispose over
|
||||
// cleanup would be worse than a leftover temp dir.
|
||||
// Best-effort by contract: swallows rm failures (EACCES/EBUSY-style — e.g. the dead
|
||||
// child left an unreadable entry behind).
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -15,11 +15,8 @@ import {
|
||||
waitForExit,
|
||||
} from '../src/index.ts'
|
||||
|
||||
// `rm` is wrapped (real-passthrough by default) so ONE test can inject a
|
||||
// rejection deterministically. A real recursive-rm failure is not portably
|
||||
// provokable — permission tricks (a chmod-000 subtree) fail only for
|
||||
// unprivileged users and are ignored by root — so this is the fs boundary
|
||||
// the testing policy sanctions mocking; everything else stays the real fs.
|
||||
// `rm` is wrapped (real-passthrough by default) so one test can inject a rejection
|
||||
// deterministically.
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return { ...actual, rm: vi.fn(actual.rm) }
|
||||
|
||||
@@ -34,7 +34,7 @@ Beside `capabilities` sits one DESCRIPTIVE fact, not validated by the service: `
|
||||
|
||||
`provider.start(request)` returns a `SubagentRun`: a handle with `started` (the publication/readiness promise), `result` (the terminal outcome), `cancel()`, `dispose()`, and the 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 a `SubagentResult` (`output`, optional `structured`, `stopReason`) — it does **not** reject on a child-level failure (a model/transport failure resolves with `stopReason: 'error'`), so the consumer maps a non-`completed` reason to an `isError` tool result. The consumer MUST `dispose()` on every path (success, error, abort) to reach child quiescence and avoid leaking an idle child / session.
|
||||
|
||||
The service 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: `subagent/start` (payload `SubagentRunInfo`) fires only after `run.started` fulfills, and `subagent/end` (payload `SubagentRunEndInfo`) fires only for that announced run; 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. The service observes `result` immediately even while readiness is pending, clones its output before the caller can mutate it, and buffers that end payload until start has fired; a rejecting result cannot become an unhandled detached promise, start always precedes end, and a listener cannot corrupt the caller's result. `subagent/end` carries the cloned output as `lastAssistantMessage` on the settle path and omits it on infrastructure rejection. Any run-affecting decision is out of scope for this observe-only surface.
|
||||
The service emits provider-added and provider-removed after registry changes, so consumers track membership without assuming sibling load order. A run emits `subagent/start` only after readiness and `subagent/end` only after that announced run settles; readiness rejection emits neither. Both are observe-only. Result settlement is observed immediately, cloned, and buffered until start to prevent unhandled rejection, preserve start-before-end ordering, and isolate listener mutation. Settled output appears as `lastAssistantMessage`; infrastructure rejection omits it. Remote providers need not publish a local agent.
|
||||
|
||||
## Scope (first cut)
|
||||
|
||||
|
||||
@@ -1,34 +1,8 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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).
|
||||
* @module @deepseek-ai/dsh-subagent
|
||||
*/
|
||||
|
||||
@@ -85,16 +59,10 @@ declare module 'cordis' {
|
||||
*/
|
||||
'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.
|
||||
* A subagent run started — emitted only after {@link SubagentRun.started} fulfills, when
|
||||
* the provider has established a live child.
|
||||
*
|
||||
* Scope-filtered dispatch: keyed to the delegating parent.
|
||||
* @param info - which provider started which child agent.
|
||||
* @mode emit
|
||||
*/
|
||||
@@ -104,10 +72,8 @@ declare module 'cordis' {
|
||||
* 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.
|
||||
* Dispatch is scoped to the delegating parent.
|
||||
* Scope-filtered dispatch: keyed to the delegating parent.
|
||||
* @param info - the run identity plus stop reason and final output.
|
||||
* @mode emit
|
||||
*/
|
||||
@@ -165,16 +131,8 @@ 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;
|
||||
* 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.
|
||||
* Register a provider under its `provider.name`.
|
||||
*
|
||||
* @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
|
||||
@@ -182,10 +140,6 @@ export class SubagentService extends Service {
|
||||
*/
|
||||
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 capabilities: SubagentCapabilities = Object.freeze({
|
||||
outputSchema: provider.capabilities.outputSchema,
|
||||
depthLimit: provider.capabilities.depthLimit,
|
||||
@@ -203,24 +157,16 @@ export class SubagentService extends Service {
|
||||
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.
|
||||
// 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.
|
||||
yield () => {
|
||||
this.providers.delete(snapshot.name)
|
||||
this.emitLifecycle('subagent/provider-removed', snapshot.name)
|
||||
}
|
||||
this.ctx.emit('subagent/provider-added', snapshot)
|
||||
}.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 the exact Cordis disposer so generator effects preserve teardown nesting.
|
||||
return dispose
|
||||
}
|
||||
|
||||
@@ -243,13 +189,8 @@ export class SubagentService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}, then emits `subagent/start` /
|
||||
* `subagent/end` only after the run's readiness boundary fulfills. A provider
|
||||
* that fails before establishing a child emits neither event.
|
||||
* Start a subagent run on the named provider.
|
||||
*
|
||||
* @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).
|
||||
@@ -266,10 +207,7 @@ export class SubagentService extends Service {
|
||||
this.assertCapabilities(provider, request)
|
||||
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.
|
||||
// Detach every data field before crossing into a provider.
|
||||
const accepted: SubagentStartRequest = {
|
||||
prompt: structuredClone(request.prompt),
|
||||
parent,
|
||||
@@ -282,11 +220,7 @@ export class SubagentService extends Service {
|
||||
}
|
||||
const run = provider.start(accepted)
|
||||
|
||||
// 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.
|
||||
// Observe result settlement IMMEDIATELY, before waiting on readiness.
|
||||
let readiness: 'pending' | 'started' | 'failed' = 'pending'
|
||||
let pendingEnd: SubagentRunEndInfo | undefined
|
||||
const deliverEnd = (info: SubagentRunEndInfo): void => {
|
||||
@@ -298,10 +232,7 @@ export class SubagentService extends Service {
|
||||
}
|
||||
void run.result.then(
|
||||
(result) => {
|
||||
// Snapshot before the caller's own `await run.result` continuation. Even
|
||||
// when readiness is still pending, buffering the clone rather than the
|
||||
// caller-owned result keeps the eventual observe-only event immutable
|
||||
// with respect to consumer mutation.
|
||||
// Snapshot before the caller's own `await run.result` continuation.
|
||||
let lastAssistantMessage: SubagentResult['output'] | undefined
|
||||
try {
|
||||
lastAssistantMessage = structuredClone(result.output)
|
||||
@@ -318,12 +249,7 @@ export class SubagentService extends Service {
|
||||
() => { deliverEnd({ provider: name, id: run.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.
|
||||
// Readiness is the publication boundary owned by the provider.
|
||||
void run.started.then(
|
||||
() => {
|
||||
readiness = 'started'
|
||||
@@ -343,24 +269,10 @@ export class SubagentService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a `subagent/*` lifecycle event with PER-LISTENER containment: dispatch
|
||||
* each subscriber individually and log (never propagate) a thrown one, 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. 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 a `subagent/*` lifecycle event with PER-LISTENER containment: dispatch each
|
||||
* subscriber individually and log (never propagate) a thrown one, 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.
|
||||
*/
|
||||
private emitLifecycle(name: 'subagent/start', info: SubagentRunInfo, parent: Agent): void
|
||||
private emitLifecycle(name: 'subagent/end', info: SubagentRunEndInfo, parent: Agent): void
|
||||
@@ -370,10 +282,9 @@ 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.
|
||||
// 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.
|
||||
const dispatchArgs: unknown[] = parent === undefined
|
||||
? [name, info]
|
||||
: [scopeTarget(this, parent), name, info]
|
||||
|
||||
@@ -11,16 +11,10 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { StructuredOutputSchema } 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.
|
||||
* 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).
|
||||
*/
|
||||
export interface SubagentCapabilities {
|
||||
/** Honor {@link SubagentStartRequest.outputSchema} (structured final output). */
|
||||
|
||||
@@ -80,11 +80,10 @@ describe('SubagentService', () => {
|
||||
})
|
||||
|
||||
it('contains a throwing provider-removed listener: later mirrors still hear it, teardown completes', async () => {
|
||||
// provider-removed fires inside the registration's DISPOSER, so a
|
||||
// propagating listener would disrupt the backend's teardown; and cordis
|
||||
// emit halts on the first throw, so an uncontained one would starve every
|
||||
// mirror registered after it (a stale model-facing tool). Both are
|
||||
// prevented by per-listener containment.
|
||||
// provider-removed fires inside the registration's DISPOSER, so a propagating listener
|
||||
// would disrupt the backend's teardown; and cordis emit halts on the first throw, so an
|
||||
// uncontained one would starve every mirror registered after it (a stale model-facing
|
||||
// tool).
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
const warnings: string[] = []
|
||||
@@ -429,11 +428,8 @@ describe('SubagentService', () => {
|
||||
})
|
||||
|
||||
it('observe-only: a subagent/end listener mutating lastAssistantMessage cannot corrupt the caller\'s result', async () => {
|
||||
// The subagent/end emit fires from a detached `.then` registered before
|
||||
// start() returns — i.e. BEFORE the caller's own `await run.result`
|
||||
// continuation. If the event shared the result.output reference, a mutating
|
||||
// listener would change the SubagentResult the caller consumes. The service
|
||||
// deep-clones output onto the event, so the listener mutates only its copy.
|
||||
// The subagent/end emit fires from a detached `.then` registered before start() returns —
|
||||
// i.e.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider(new StubProvider(
|
||||
@@ -484,11 +480,7 @@ describe('SubagentService', () => {
|
||||
})
|
||||
|
||||
it('contains a structuredClone failure: emits subagent/end without lastAssistantMessage (no unhandled rejection)', async () => {
|
||||
// The clone runs inside onFulfilled, OUTSIDE emitLifecycle's per-listener
|
||||
// containment. An uncloneable output (here a content block carrying a
|
||||
// function) would otherwise throw and become an unhandled rejection on the
|
||||
// detached `.then`. The handler must instead log and emit the event WITHOUT
|
||||
// lastAssistantMessage, still carrying the real stopReason.
|
||||
// The clone runs inside onFulfilled, outside emitLifecycle's per-listener containment.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
const warn = vi.fn(); ctx.logger.warn = warn as never
|
||||
@@ -552,9 +544,7 @@ describe('SubagentService', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider(new StubProvider('contain'))
|
||||
// Two listeners; the FIRST throws. Per-listener containment means the second
|
||||
// must STILL run (a single try/catch around ctx.emit would let the first
|
||||
// throw halt the dispatch and starve the second — the round-2 regression).
|
||||
// Two listeners; the FIRST throws.
|
||||
const second = vi.fn()
|
||||
ctx.on('subagent/start', () => { throw new Error('bad start listener') })
|
||||
ctx.on('subagent/start', second)
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
# @deepseek-ai/dsh-tool-subagent
|
||||
|
||||
The model-facing `subagent` tool: delegate a self-contained task to a child agent and return its final output. Pure schema + lifecycle shaping over the [`ctx.subagents`](../subagent/README.md) provider registry — an in-process, ACP, or future A2A backend swaps in without changing what the model sees.
|
||||
Model-facing delegation tool over the [`ctx.subagents`](../subagent/README.md) provider registry. The selected provider may be in-process or out-of-process without changing the model's `{ description, prompt }` request shape.
|
||||
|
||||
## Provider selection is config, not model-facing
|
||||
## Provider binding
|
||||
|
||||
This plugin binds to **exactly one** provider (`Config.provider`). The model sees only `{ description, prompt }` — there is no provider/type parameter in the schema. To expose more than one transport, load the plugin more than once, each bound to a different provider **and a distinct `toolName`** (the tool registry rejects a duplicate name, so a second load that kept the default `subagent` name would throw). Keeping selection in config (not the schema) is the deliberate split: the *service* holds a multi-provider registry; the *tool* picks one.
|
||||
Each plugin load binds one `Config.provider`. To expose multiple providers, load the plugin under distinct `toolName` values. The tool description is derived from `provider.inheritsParentContext`, telling the model whether the child already sees completed parent turns.
|
||||
|
||||
## The description states the provider's context contract
|
||||
|
||||
The tool description and the `prompt` parameter description are DERIVED from the bound provider's `inheritsParentContext` (`providerWording`): a fresh-context provider (spawn, ACP) gets the standalone-prompt wording ("it does not see this conversation"), an inheriting provider (fork) tells the model the child already sees the conversation's completed turns and its prompt should state only what is new. Because the description is fixed at tool registration, the tool **mirrors the provider's lifecycle** (`subagent/provider-added`/`-removed`): it registers when the bound provider is (or becomes) available and unregisters when the provider goes away — no load-order requirement (the cordis Loader starts sibling entries concurrently, so "listed first" never guaranteed "registered first"), and an HMR reload of the backend re-derives the wording from the fresh provider. While the provider is absent the tool simply does not exist (a `ctx.logger` note records the wait; a typo'd provider name shows up as a tool that never materializes).
|
||||
The tool follows provider availability through `subagent/provider-added` and `subagent/provider-removed`; it has no Loader-order dependency and disappears while its provider is absent.
|
||||
|
||||
| Config key | Meaning |
|
||||
|---|---|
|
||||
| `provider` (required) | The `ctx.subagents` provider name to start runs on (`spawn`, `fork`, `acp`, …). |
|
||||
| `toolName` | The model-facing tool name to register (default `subagent`). Set a distinct value per load when exposing multiple providers, e.g. `subagent` + `subagent_acp`. |
|
||||
| `agentOptions` | Default per-child `{ model? }` applied to every spawned child. |
|
||||
| `persona` | Per-child persona that shadows the deployment persona; requires the provider's `persona` capability. |
|
||||
| `toolFilter` | Per-child `{ allow?, deny? }` restriction over global tools; requires the provider's `toolFilter` capability. |
|
||||
| `maxDepth` | Maximum delegation depth; requires the provider's `depthLimit` capability. |
|
||||
| `provider` (required) | Provider name on `ctx.subagents`. |
|
||||
| `toolName` | Model-facing name (default `subagent`). |
|
||||
| `agentOptions` | Default child options (`model?`). |
|
||||
| `persona` | Child persona; requires provider support. |
|
||||
| `toolFilter` | Child global-tool restriction; requires provider support. |
|
||||
| `maxDepth` | Delegation-depth cap; requires provider support. |
|
||||
|
||||
## Lifecycle (synchronous collect)
|
||||
## Execution
|
||||
|
||||
`execute` starts a run on the configured provider and **awaits `run.result` inside a `try/finally` that always `dispose()`s the run** — the owned child agent/session is torn down on every path (success, error, abort), never leaked. The tool's abort signal (`exec.signal`) is bridged to `run.cancel()`. A non-`completed` stop reason (aborted/error/max-tokens/refusal) maps to an `isError` tool result rather than returning partial output as success.
|
||||
|
||||
Background / poll collection is deferred (see the [RFC](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md)); this cut blocks the parent turn until the child finishes.
|
||||
`execute` starts a run, bridges the tool abort signal to `run.cancel()`, awaits `run.result`, and always disposes the run. Non-completed stop reasons return error tool results rather than successful partial output. Collection is synchronous; background polling remains deferred in the [subagent seam RFC](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md).
|
||||
|
||||
@@ -1,32 +1,8 @@
|
||||
/**
|
||||
* The model-facing `subagent` tool: delegate a task to a child agent and return
|
||||
* its final output. Pure schema + lifecycle shaping — every transport concern
|
||||
* lives behind the `ctx.subagents` provider registry
|
||||
* (`@deepseek-ai/dsh-subagent`), so an in-process, ACP, or future A2A backend
|
||||
* swaps in without touching what the model sees.
|
||||
*
|
||||
* Provider selection is config, not model-facing: this plugin is bound to
|
||||
* EXACTLY ONE provider name (`Config.provider`). To expose more than one
|
||||
* transport, load the plugin more than once, each bound to a different provider
|
||||
* — there is no provider/type parameter in the model-facing schema. The model
|
||||
* sees only `{ description, prompt }`.
|
||||
*
|
||||
* The tool DESCRIPTION is derived from the bound provider's context contract
|
||||
* ({@link providerWording}): a fresh-context provider (spawn, ACP) gets the
|
||||
* standalone-prompt wording, an inheriting provider (fork) tells the model the
|
||||
* child already sees the conversation's completed turns. The tool MIRRORS the
|
||||
* provider's lifecycle via `subagent/provider-added`/`-removed` — it registers
|
||||
* when the provider is (or becomes) available and unregisters when the
|
||||
* provider goes away — so no load-order requirement exists and an HMR reload
|
||||
* of the backend re-derives the wording from the fresh provider.
|
||||
*
|
||||
* Collection is SYNCHRONOUS this cut: `execute` starts a run and awaits
|
||||
* `run.result` inside a `try/finally` that always disposes the run, so the
|
||||
* owned child agent/session is torn down on every path (success, error, abort)
|
||||
* and never leaks as a live idle child. A non-`completed` stop reason maps to an
|
||||
* `isError` tool result (by throwing) rather than returning partial output as
|
||||
* success.
|
||||
*
|
||||
* The model-facing `subagent` tool: delegate a task to a child agent and return its final
|
||||
* output. Pure schema + lifecycle shaping — every transport concern lives behind the
|
||||
* `ctx.subagents` provider registry (`@deepseek-ai/dsh-subagent`), so an in-process, ACP, or
|
||||
* future A2A backend swaps in without touching what the model sees.
|
||||
* @module @deepseek-ai/dsh-tool-subagent
|
||||
*/
|
||||
|
||||
@@ -101,16 +77,8 @@ export const Config: z<Config> = z.object({
|
||||
model: z.string(),
|
||||
}).default(undefined as unknown as { model: string }),
|
||||
persona: z.string(),
|
||||
// A schemastery object materializes {} (with [] for nested arrays) when the
|
||||
// key is omitted — for toolFilter that would mean an EMPTY ALLOW-LIST, i.e.
|
||||
// deny-everything, silently. Force the omitted key to stay absent (the same
|
||||
// shape discipline as SystemPrompt's toolOrder); the cast is needed because
|
||||
// .default() expects the object type.
|
||||
// The NESTED arrays get the same treatment as the object itself: a partial
|
||||
// filter ({deny: […]}) must not materialize allow: [] beside it — an empty
|
||||
// allow-list means deny-EVERYTHING, so the materialized default would turn
|
||||
// a deny-one config into deny-all. An EXPLICIT allow: [] (grant-only
|
||||
// children) survives, since only the omitted key defaults to undefined.
|
||||
// A schemastery object materializes {} (with [] for nested arrays) when the key is omitted —
|
||||
// for toolFilter that would mean an EMPTY ALLOW-LIST, i.e. deny-everything, silently.
|
||||
toolFilter: z.object({
|
||||
allow: z.array(z.string()).default(undefined as unknown as string[]),
|
||||
deny: z.array(z.string()).default(undefined as unknown as string[]),
|
||||
@@ -194,14 +162,10 @@ export function apply(ctx: Context, config: Config): void {
|
||||
if (config.toolFilter !== undefined && config.toolFilter.allow === undefined && config.toolFilter.deny === undefined) {
|
||||
throw new Error('tool-subagent: `toolFilter` is configured but names neither `allow` nor `deny` — remove the key or fill the filter')
|
||||
}
|
||||
// The tool MIRRORS its provider's lifecycle instead of assuming load order:
|
||||
// the cordis Loader starts sibling entries concurrently, so "backend listed
|
||||
// first in cordis.yml" does not guarantee "provider registered first", and
|
||||
// an HMR reload of the backend replaces the provider while this fiber stays
|
||||
// loaded. Register the tool when the bound provider is (or becomes)
|
||||
// available — deriving the wording from THAT provider — and unregister it
|
||||
// when the provider goes away, so the description can never outlive or
|
||||
// predate the provider it describes.
|
||||
// The tool MIRRORS its provider's lifecycle instead of assuming load order: the cordis Loader
|
||||
// starts sibling entries concurrently, so "backend listed first in cordis.yml" does not
|
||||
// guarantee "provider registered first", and an HMR reload of the backend replaces the
|
||||
// provider while this fiber stays loaded.
|
||||
let disposeTool: (() => Promise<void> | void) | undefined
|
||||
const mount = (provider: SubagentProvider): void => {
|
||||
const wording = providerWording(provider.inheritsParentContext)
|
||||
@@ -245,10 +209,8 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// aborted while the child is in flight, cancel the child too.
|
||||
const onAbort = (): void => { run.cancel('parent step aborted') }
|
||||
exec.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
// `addEventListener` does NOT fire for a signal already aborted before this
|
||||
// line, so a step cancelled before the tool ran would never reach the
|
||||
// child. Cancel explicitly in that case — the bridge must honor an
|
||||
// already-aborted signal, not lean on each provider re-checking it.
|
||||
// `addEventListener` does not fire for a signal already aborted before this line, so a
|
||||
// step cancelled before the tool ran would never reach the child.
|
||||
if (exec.signal?.aborted) run.cancel('parent step aborted')
|
||||
|
||||
try {
|
||||
@@ -269,16 +231,8 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}))
|
||||
}
|
||||
|
||||
// Listeners first, then the presence check: both run synchronously, so no
|
||||
// registration can slip between them; the `disposeTool === undefined` guard
|
||||
// makes a same-tick added-event after a successful mount a no-op.
|
||||
// TODO(subagent-dup-toolname): two WAITING fibers configured with the same
|
||||
// toolName collide only when their provider finally arrives — the duplicate
|
||||
// tool-name throw then propagates through `subagent/provider-added` and
|
||||
// rolls back the PROVIDER registration, so an invalid config blasts the
|
||||
// backend's fiber instead of the misconfigured tool's. Config-time detection
|
||||
// would need a cross-fiber registry of intended tool names; revisit if a
|
||||
// real deployment ever hits it.
|
||||
// Register listeners before the synchronous presence check to avoid an activation gap.
|
||||
// TODO(subagent-dup-toolname): validate intended tool names before provider activation.
|
||||
ctx.on('subagent/provider-added', (provider) => {
|
||||
if (provider.name === config.provider && disposeTool === undefined) mount(provider)
|
||||
})
|
||||
@@ -292,8 +246,6 @@ export function apply(ctx: Context, config: Config): void {
|
||||
mount(present)
|
||||
} else {
|
||||
// Not an error: the backend's fiber may simply activate after this one.
|
||||
// The tool appears the moment the provider registers; a typo'd provider
|
||||
// name shows up as this note plus a tool that never materializes.
|
||||
ctx.logger.info(`subagent provider "${config.provider}" not registered yet; the "${config.toolName ?? 'subagent'}" tool will register when it appears`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,12 +370,9 @@ describe('dsh-tool-subagent', () => {
|
||||
|
||||
const controller = new AbortController()
|
||||
const pending = callSubagent(ctx, { description: 'd', prompt: 'p' }, { signal: controller.signal })
|
||||
// Abort AFTER the tool body has had a chance to register its abort listener
|
||||
// (ctx.tools.execute now awaits the tools/pre-execute waterfall before the
|
||||
// body runs, so the listener is not registered synchronously). A few
|
||||
// microtask turns let execute() reach `addEventListener('abort')`, so this
|
||||
// exercises the LIVE onAbort bridge — distinct from the already-aborted
|
||||
// sync path the next test covers.
|
||||
// Abort after the tool body has had a chance to register its abort listener
|
||||
// (ctx.tools.execute now awaits the tools/pre-execute waterfall before the body runs, so
|
||||
// the listener is not registered synchronously).
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
controller.abort()
|
||||
@@ -385,11 +382,9 @@ describe('dsh-tool-subagent', () => {
|
||||
})
|
||||
|
||||
it('cancels the run when the tool signal is ALREADY aborted before execute (no missed abort)', async () => {
|
||||
// `addEventListener('abort')` does not fire for a signal already aborted
|
||||
// before the listener is added, so a step cancelled before the tool ran
|
||||
// would never reach the child unless the bridge re-checks `signal.aborted`.
|
||||
// A provider that leans only on the abort EVENT (this spy never inspects
|
||||
// request.signal) proves the bridge itself must cancel.
|
||||
// `addEventListener('abort')` does not fire for a signal already aborted before the
|
||||
// listener is added, so a step cancelled before the tool ran would never reach the child
|
||||
// unless the bridge re-checks `signal.aborted`.
|
||||
const cancelled = vi.fn()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
@@ -442,10 +437,7 @@ describe('dsh-tool-subagent', () => {
|
||||
})
|
||||
|
||||
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/Config/apply', () => {
|
||||
// Postmortem 0001 guard: this plugin HAS `inject = ['tools','subagents']`, so
|
||||
// a stray `export default apply` would collapse the module via
|
||||
// `unwrapExports` (`exports.default ?? exports`), DROP `inject`, and crash at
|
||||
// load with "cannot get property … without inject". Guard the shape directly.
|
||||
// Loader must retain this namespace's injection metadata.
|
||||
expect('default' in tool).toBe(false)
|
||||
expect(tool.name).toBe('tool-subagent')
|
||||
expect(tool.inject).toEqual(['tools', 'subagents'])
|
||||
|
||||
Reference in New Issue
Block a user