Merge remote-tracking branch 'origin/master' into worktree/provider-routed-llm-adapters

# Conflicts:
#	docs/config-catalog.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.md
#	docs/event-producer-consumer.md
#	docs/persistence-catalog.md
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl
#	examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl
#	examples/acp-agent/tests/snapshots/skill-load/session.jsonl
#	examples/acp-agent/tests/snapshots/text-turn/session.jsonl
#	examples/sandbox-acp-agent/cordis.yml
#	examples/sandbox-acp-agent/tests/snapshots/escalation-approved/session.jsonl
#	examples/sandbox-acp-agent/tests/snapshots/escalation-rejected/session.jsonl
#	examples/sandbox-acp-agent/tests/snapshots/mode-switching/session.jsonl
#	packages/compact/compact-basic/README.md
#	packages/compact/compact-basic/src/index.ts
#	packages/compact/compact-basic/tests/compact-basic.spec.ts
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/src/loop.ts
#	packages/core/agent-loop/tests/properties.spec.ts
#	packages/core/session/README.md
#	packages/core/session/src/types.ts
#	packages/core/session/tests/derived-cache.spec.ts
#	packages/llm/llm-deepseek/src/index.ts
#	packages/llm/llm-pi-ai/README.md
#	packages/llm/llm-pi-ai/src/adapter.ts
#	packages/llm/llm-pi-ai/src/convert.ts
#	packages/llm/llm-pi-ai/tests/adapter.spec.ts
#	packages/llm/llm/README.md
#	packages/llm/llm/src/call-config.ts
#	packages/llm/llm/src/index.ts
#	packages/ui/acp-agent/src/index.ts
#	packages/ui/acp/tests/harness.ts
#	packages/ui/jsonrpc/README.md
#	packages/ui/jsonrpc/src/server.ts
#	packages/ui/stdio-agent/README.md
#	packages/ui/stdio-agent/src/index.ts
#	python/sdk/README.i18n.yaml
This commit is contained in:
Yichen Jiang
2026-07-14 22:17:50 +08:00
672 changed files with 10295 additions and 14200 deletions

View File

@@ -33,7 +33,7 @@ ACP advertises no start-time capabilities because this process cannot enforce th
config:
providerName: acp
command: node
args: ['--import', 'tsx', './packages/ui/acp-agent/src/bin.ts', './examples/acp-agent/cordis.yml']
args: ['--import', 'tsx', './packages/ui/acp-agent/src/bin.ts', '--config', './examples/acp-agent/cordis.yml']
permission: reject
env:
DEEPSEEK_API_KEY: !!js process.env.DEEPSEEK_API_KEY
@@ -56,3 +56,25 @@ The child environment is built by [`buildChildEnv`](../subagent-subprocess/READM
The package has no default export. Cordis loader unwrapping would otherwise hide the named `inject` metadata; see [postmortem 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md).
Keyless tests drive a scripted ACP subprocess over real stdio. The with-key e2e drives the repository's real ACP agent and self-skips without `DEEPSEEK_API_KEY`.
## Model Experience
### Child-agent request
**What the model sees**: The remote child receives the standalone task content through ACP plus its own process's configured system prompt, tools, and fresh session. It receives no parent conversation. This provider advertises no optional start-time capabilities, so the local service rejects requests for persona, tool filtering, depth enforcement, or structured output instead of silently omitting them.
**Token effect**: The child pays for an independent full context and its own multi-step history. These tokens never enter the parent's context.
### Parent tool result, indirectly
**What the model sees**: Through `dsh-tool-subagent`, the parent receives only the child's final streamed assistant text or that consumer's exact stop-reason error, not intermediate messages or tool traffic. A request already cancelled before publication becomes exactly `Error: subagent request was aborted before the ACP child started`; other start failures pass through as `Error: <message>`.
**Token effect**: Parent input grows only by the final result or error, which is data-dependent and retained until compaction. This provider adds no parent schema itself.
## Known Limitations and Deferred Work
- **A fresh process per run** — persistent-process pooling is a future optimization ([the seam RFC](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md)).
- **No optional start-time capabilities** — this provider cannot apply the local harness's `outputSchema`, depth cap, tool filter, or persona inside the remote process, so it advertises none and the service rejects requests that require them.
- **Only `agent_message_chunk` text is collected** — the child's tool-call activity, thought chunks, and plan updates are not surfaced to the parent.
- **Permission prompts are auto-answered** (`permission: allow | reject`) — no human is surfaced a child's `session/request_permission` in this cut.
- **No snapshot-tier replay coverage** (`TODO(acp-subagent-replay)`) — an ACP child is its own process with its own replay shape, deferred.

View File

@@ -1,20 +1,8 @@
/**
* 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).
*
* Out-of-process ACP subagent backend. Each child has its own process, session, model, and
* tools, so it shares no Cordis context, ignores `request.parent`, and advertises no parent-
* enforced start capabilities. This plugin uses named exports only; a default would hide its
* loader metadata (see `docs/postmortem/0001-acp-default-export-drops-inject.md`).
* @module @deepseek-ai/dsh-subagent-acp
*/

View File

@@ -1,24 +1,10 @@
/**
* 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.
* Fresh-process ACP subagent client. Drives one child session and owns cancellation and
* quiescent disposal.
*
* TODO(acp-subagent-replay): add snapshot-tier coverage with a separate replay fixture and
* sessions root inside each child process. Current keyless coverage uses a scripted ACP child;
* with-key coverage drives the real ACP example.
* @module @deepseek-ai/dsh-subagent-acp/run
*/
@@ -42,16 +28,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
import { buildChildEnv, disposeChildProcess, spawnFailure } from '@deepseek-ai/dsh-subagent-subprocess'
/**
* How the client answers a child's `session/request_permission`. The first cut
* does not surface permission prompts to a human, so every request is
* auto-answered by this fixed policy:
*
* - `reject` — decline every prompt (answer `cancelled`). Safe default: a child
* that asks before a side effect does not get to take it.
* - `allow` — approve every prompt by selecting its first `allow_*` option (or,
* if none is offered, `cancelled`). Use when the child is trusted to act.
*/
/** Fixed response to child permission requests: reject by default, or select the first allow option. */
export type PermissionPolicy = 'allow' | 'reject'
/** Resolved spawn spec for an ACP child process (no defaults — see Config). */
@@ -95,18 +72,7 @@ export interface AcpRunSpec {
onError?: (error: Error, stopReason: SubagentStopReason) => void
}
/**
* 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.
*/
/** EOF grace for child flush and nested-process teardown; wider than the signal grace below. */
export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000
/** Default grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config; mirrors the bash executor). */
@@ -174,16 +140,9 @@ 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 after publication resolves with `stopReason: 'error'`. A spawn,
* initialize, new-session, or pre-publication cancellation failure instead
* rejects only after the process has been reaped. `dispose()` requests ACP
* cancellation, then kills and reaps the subprocess.
* Start and publish one ACP child after initialization and session creation.
* Child failures resolve through the run result; startup failures reject after
* process reap. Disposal cancels, kills, and reaps the child.
* @param request - the start request; its signal is the cancellation channel.
* @param spec - the resolved spawn spec: command/args/cwd, env, permission
* policy, dispose graces, and the optional error sink.
@@ -194,24 +153,16 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
if (request.signal.aborted) throw new Error('subagent request was aborted before the ACP child started')
// Spawn the child ACP agent. stdin = ACP request channel, stdout = ACP
// response channel, stderr = INHERIT so the child's diagnostics surface on the
// parent's stderr (no separate capture to drain — we don't fold child stderr
// into the result; the seam reports only output + stop reason).
// Keep diagnostics on parent stderr; only ACP output contributes to the result.
const child = spawn(spec.command, spec.args, {
cwd: spec.cwd,
env: buildChildEnv(spec.env),
stdio: ['pipe', 'pipe', 'inherit'],
})
// Same-tick capture (the library's contract): a spawn-level failure (e.g.
// ENOENT for a bad command) is an `error` EVENT that would crash the parent
// unheard; the result path races this promise, so a bad command settles
// `error` like any child failure.
// Capture the child-process error event immediately.
const spawnFailed = spawnFailure(child)
// One memoized quiescence transaction is shared by startup rollback and the
// published run's disposer. Once start fulfills, only the holder can invoke
// it; before fulfillment the provider invokes it on every failure path.
// Startup rollback and the published handle share one process teardown.
let processDisposal: Promise<void> | undefined
const disposeProcess = (): Promise<void> => (processDisposal ??= disposeChildProcess(child, {
disposeEofGraceMs: spec.disposeEofGraceMs,
@@ -220,12 +171,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
// Accumulate the child's streamed assistant text — the SubagentResult output.
const output: string[] = []
// `cancelled` records that the required signal or disposal requested 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`).
// Shared mutable state keeps cancellation visible across async closures.
const flags = { cancelled: false }
const makeClient = (_agent: AcpAgent): Client => ({
@@ -261,28 +207,14 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
)
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 signal/dispose cancellation 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).
// Cancellation settles the result without waiting for a cooperative child.
let signalCancelSettled!: () => void
const cancelSettled = new Promise<void>((resolve) => { signalCancelSettled = resolve })
const requestCancel = (): void => {
if (flags.cancelled) return
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 ACP cancel; process teardown remains authoritative.
/* v8 ignore next */
if (sessionId !== undefined) void conn.cancel({ sessionId }).catch(() => { /* child gone / no session */ })
}
@@ -324,12 +256,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
const result: Promise<SubagentResult> = (async (): Promise<SubagentResult> => {
try {
// Race two post-publication 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 (`result` settles `aborted`). After `newSession`
// succeeds, transport/process failure rejects the in-flight prompt RPC.
// Race the remote turn against local cancellation.
const prompt = async (): Promise<SubagentResult> => {
// The startup phase cannot fulfill without assigning the session id.
const promptResult = await conn.prompt({ sessionId: sessionId as string, prompt: toAcpPrompt(request.prompt) })
@@ -340,23 +267,14 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
cancelSettled.then((): SubagentResult => ({ output: collectOutput(), stopReason: 'aborted' })),
])
} catch (error: unknown) {
// A deterministic cancellation resolves `cancelSettled` before its
// best-effort ACP cancel can reject the prompt. This fallback is only for
// a process/pipe rejection already queued when the abort event fires; its
// first-outcome ordering cannot be forced without a timing-dependent test.
// Cover a process rejection already queued when cancellation arrives.
/* v8 ignore next */
if (flags.cancelled) return { output: collectOutput(), stopReason: 'aborted' }
// The seam contract: result resolves (never rejects) on a child-level
// failure. Startup failures were already rejected before publication;
// every rejection here is a prompt transport/RPC failure.
// Flatten to `error` and surface the original via onError so a real fault
// is preserved rather than silently lost.
// Flatten post-publication transport failures while preserving diagnostics.
try {
spec.onError?.(toError(error), 'error')
} catch {
// Swallows only the caller-supplied sink's OWN throw: an unguarded
// sink exception would reject `result` and break the contract above.
// The child-level failure being reported still settles as `error`.
// The diagnostic sink cannot reject the run result.
}
return { output: collectOutput(), stopReason: 'error' }
} finally {
@@ -372,15 +290,9 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
if (disposal !== undefined) return disposal
request.signal.removeEventListener('abort', onAbort)
requestCancel()
// 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).
// The shared EOF → TERM → KILL ladder awaits exit. ACP normally quiesces
// from stdin EOF, including the final flush, so this backend uses a wider
// EOF grace before signals escalate.
disposal = disposeProcess()
return disposal
},

View File

@@ -1,44 +1,9 @@
/**
* 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.
*
* Minimal no-network ACP child process for keyless backend tests. Environment variables script its
* text and stop reason, a cancel-cooperative or cancel-ignoring hang, permission requests, and a
* readiness marker. Disposal fixtures can delay an EOF flush, ignore EOF but exit and mark
* SIGTERM, or trap SIGTERM to require SIGKILL. The specs spawn this non-test module under tsx with
* an explicit tsconfig, mirroring real example boot.
* @module @deepseek-ai/dsh-subagent-acp/tests/mock-acp-server
*/
@@ -159,11 +124,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 receives cancellation but neither resolves nor exits. The
// backend must still settle `aborted`, and disposal must kill the process.
return Promise.resolve()
}
resolveCancel?.('cancelled')
@@ -180,12 +142,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. READY_FILE proves the trap was armed before the test disposes the run.
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.
@@ -193,13 +152,10 @@ 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 its own. A signal sent before MOCK_FLUSH_DELAY_MS would suppress the marker, so it proves
// the EOF grace window was long enough for durable flush.
if (FLUSH_ON_EOF !== undefined) {
const flushDelayMs = Number(process.env.MOCK_FLUSH_DELAY_MS ?? '150')
process.stdin.on('end', () => {
@@ -210,14 +166,9 @@ 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 before SIGKILL. The signal
// marker distinguishes that catchable rung from an immediate, uncatchable SIGKILL; READY_FILE
// proves the handler was armed before disposal.
if (process.env.MOCK_IGNORE_EOF === '1') {
const sigtermFile = process.env.MOCK_SIGTERM_FILE
process.on('SIGTERM', () => {

View File

@@ -9,16 +9,9 @@ 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 cross-process seam proof: the backend spawns the real acp-agent example, speaks ACP over
* stdio, and returns its real model answer. This is the out-of-process counterpart to in-process
* spawn coverage and self-skips without `DEEPSEEK_API_KEY`.
*/
// The real acp-agent example: its bin + cordis.yml (the live DeepSeek config).
@@ -48,7 +41,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive
await ctx.plugin(acp, {
providerName: 'acp',
command: process.execPath,
args: ['--import', tsxLoader, binScript, exampleConfig],
args: ['--import', tsxLoader, binScript, '--config', exampleConfig],
cwd: workdir,
permission: 'reject',
// The child harness needs the key to reach the model; forward it
@@ -57,6 +50,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive
...process.env.DEEPSEEK_API_KEY !== undefined ? { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY } : {},
...process.env.DEEPSEEK_BASE_URL !== undefined ? { DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL } : {},
TSX_TSCONFIG_PATH: repoTsconfig,
DSH_PERMISSION_MODE: 'danger-full-access',
},
})
@@ -83,7 +77,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive
await ctx.plugin(acp, {
providerName: 'acp',
command: process.execPath,
args: ['--import', tsxLoader, binScript, exampleConfig],
args: ['--import', tsxLoader, binScript, '--config', exampleConfig],
cwd: workdir,
// The child needs to act (run bash), so approve its permission prompts.
permission: 'allow',
@@ -91,6 +85,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive
...process.env.DEEPSEEK_API_KEY !== undefined ? { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY } : {},
...process.env.DEEPSEEK_BASE_URL !== undefined ? { DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL } : {},
TSX_TSCONFIG_PATH: repoTsconfig,
DSH_PERMISSION_MODE: 'danger-full-access',
},
})

View File

@@ -21,3 +21,23 @@ Fork advertises `{ outputSchema: true, depthLimit: true, toolFilter: true, perso
| 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.
## Model Experience
### Child-agent history and envelope
**What the model sees**: The child receives the parent's balanced completed-turn surface prefix, then the new task content verbatim. A configured persona shadows prompt text in the child's fresh scope; a tool restriction filters its global wire schemas, executable lookup, and Code Mode SDK bindings but not standalone guidance. The parent's tool view and authority are not inherited. An optional structured-output request adds its child-only contract. The parent's current in-flight turn is excluded.
**Token effect**: Forking duplicates retained completed history into separate child requests; the child then accumulates its own tokens independently. Persona changes repeated prompt cost, filtering changes schema or generated SDK cost, and a first-turn fork has no inherited history.
### Parent tool result, indirectly
**What the model sees**: The parent receives only the child's own final output through `dsh-tool-subagent`, not the inherited prefix or intermediate work.
**Token effect**: Parent input grows by one data-dependent final result retained until compaction.
## Known Limitations and Deferred Work
- **Runs expose no `sendMessage`/`resume`** — the optional runtime capabilities are absent on in-process runs.
- **The seed is a one-time snapshot** — the child sees the parent's completed turns as of the fork and nothing the parent logs afterwards; there is no live context sharing.

View File

@@ -1,22 +1,9 @@
/**
* 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. The seed ends at the last `turn/end`: the current tool-call turn is
* unbalanced and cannot be replayed as a valid child session.
* @module @deepseek-ai/dsh-subagent-fork
*/
@@ -45,12 +32,10 @@ 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`. The in-flight turn is excluded; before any completed turn the child starts
* fresh. Because live sequence numbers equal array indexes, the result remains a valid seed
* beginning at sequence zero.
* @param parent - the agent whose session log to slice.
* @returns the seed events, contiguous from seq 0; empty when no turn has completed.
*/

View File

@@ -135,10 +135,9 @@ 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. The seed must stop after the
// balanced first turn; including the open turn would fail invariant replay during start.
const { ctx, parent } = await setup([textResponse('done'), 'hang', textResponse('child')])
parent.send([{ type: 'text', text: 'q1' }])
await parent.whenIdle()
@@ -183,12 +182,8 @@ 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.
// `readResult` must scan only child-owned events after the seed. The child emits no assistant
// message, so scanning the whole log would incorrectly return the parent's distinctive text.
const { ctx, parent } = await setup([textResponse('parent stale'), emptyStop])
parent.send([{ type: 'text', text: 'parent question' }])
await parent.whenIdle()

View File

@@ -39,3 +39,40 @@ After fulfillment, the caller owns the run. Provider-plugin unload does not revo
- A monotonic tool guard blocks later calls after capture, and `agent/turn-stop` ends the turn after the structured result commits.
A clean turn that never commits the required structured value reports `error`; the driver does not re-prompt. All registrations ride the child fiber and disappear with it.
## Model Experience
### Child-agent request
**What the model sees**: The shared driver sends the task verbatim as the child's user message and, when requested, shadows the persona and restricts global tool schemas, lookup, execution, and Code Mode SDK bindings in the unpublished child's fresh scope; parent restrictions are not inherited, and standalone tool-guidance sections remain. Spawn supplies no history; fork supplies its balanced seed.
**Token effect**: Child input is isolated from the parent and grows through the child's own steps. A persona changes repeated prompt text; filtering changes schema or generated SDK cost but not independently registered guidance.
### Structured-output system prompt, schema, and results
**What the model sees**: A structured run adds the structured-output instruction below. It also adds a child-scoped `structured_output` definition with exact 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.` and the requested schema. This runtime-only definition is outside the generated shipped [tool package map](../../../docs/tool-catalog.md#tool-package-map). Success returns `Structured output recorded.`; a later call becomes ``Error: structured output already recorded: the run is complete, so `<tool>` is not executed``.
**Token effect**: Fixed instruction and capability tokens are paid only by that child. Result text enters the child history, while the captured value alone becomes the parent result.
#### Structured-output instruction
```markdown
When you have your final answer, you MUST report it by calling the `structured_output` tool with arguments matching its parameter schema exactly. Do not finish with a plain text answer: only the tool call counts as your result.
```
### Parent start error, indirectly
**What the model sees**: Through `dsh-tool-subagent`, invalid depth state becomes exactly `Error: agent subagentDepth must be a non-negative safe integer`, `Error: subagent child depth exceeds the safe-integer range`, or `Error: subagent depth <attempted> exceeds maxDepth <max>`. A pre-publication cancellation passes its abort reason through the registry's `Error: <message>` wrapper.
**Token effect**: Zero tokens on a successful start; only the failed parent tool call retains this text.
### Parent result, indirectly
**What the model sees**: The driver extracts only the child's own last assistant output or captured structured value; seeded parent messages and intermediate child work do not become the result.
**Token effect**: The parent receives one data-dependent result through the consumer; all other child tokens stay in the child session.
## Known Limitations and Deferred Work
- **Runs expose no `sendMessage`/`resume`** — the optional runtime capabilities are absent on in-process runs.
- **Structured capture accepts the `defineTool` schema subset only** — unsupported JSON Schema constructs fail before the child is created; a provider needing a broader schema vocabulary requires a different runtime.

View File

@@ -1,42 +1,12 @@
/**
* 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:
*
* - The scoped capture tool and instruction are ordinary assembly inputs. The
* loop logs the assembled request header, so the demand is reconstructable
* log state rather than a wire-only mutation. As with every other assembly
* contribution, an expert `system-prompt/assemble` listener that deliberately
* removes or replaces either input owns the resulting composition.
* - `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 tool, prompt instruction, terminal guard, and authoritative
* result capture for in-process subagents. Each child registers its real schema on its own
* scope, so concurrent runs do not interact and disposal leaves no global residue. The prompt
* contribution is ordinary reconstructed request state.
*
* Capture commits only after the authoritative `tools/result` succeeds; Code Mode capture also
* waits for the enclosing `run_code` result. The terminal turn-stop and monotonic tool guard
* then prevent later listeners or calls from reopening a completed structured run.
* @module @deepseek-ai/dsh-subagent-inprocess/structured
*/
@@ -70,11 +40,8 @@ export interface StructuredAttachment {
}
/**
* 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.
* Attach the scoped capture tool, instruction, and enforcement to a child during
* its creation window. Child disposal removes every registration.
* @param childCtx - the child agent's scope context (`setup`'s argument).
* @param schema - the trusted, already-asserted schema subset to enforce (see
* `assertSupportedOutputSchema` in dsh-tools).

View File

@@ -36,12 +36,9 @@ const SCHEMA: StructuredOutputSchema = {
}
/**
* Real loop + scripted mock model + an INLINE fresh-conversation provider over the
* shared driver. The concrete backend plugins are deliberately NOT loaded —
* they would devDep-cycle this package (spawn/fork already depend on the
* driver), and the runtime under test is the driver's; plugin-level structured
* coverage lives in the spawn/fork specs. The mock model script drives the
* child's structured_output calls.
* Real loop, scripted model, and inline fresh-conversation provider over the shared driver. Loading
* spawn/fork here would create a dev-dependency cycle; their specs cover plugin integration while
* this fixture isolates driver behavior and scripts the child's `structured_output` calls.
*/
async function setup(script: Script, options: SetupOptions = {}) {
const ctx = new Context()
@@ -216,11 +213,9 @@ describe('in-process structured output', () => {
])
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'stop' }))
let wrapperInstalled = false
// Register before the ready-only start. The child session-start boundary is
// after unpublished setup attached structured output but before the loop
// can run. The wrapper awaits the
// explicit downstream stop above, then overwrites that result with continue.
// The later terminal checkpoint still wins.
// Register before ready-only start: structured output is attached before session-start and the
// loop. The wrapper waits for a downstream stop, rewrites it to continue, and must still lose
// to the later terminal checkpoint.
ctx.on('agent/session-start', (child) => {
if (child === parent) return
wrapperInstalled = true
@@ -244,10 +239,8 @@ 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.
// A downstream policy stops, then a later wrapper delegates and queues steering that ordinary
// folding would turn into continue. The terminal checkpoint must discard that steering.
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'stop' }))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
ctx.on('agent/session-start', (child) => {

View File

@@ -17,3 +17,22 @@ Spawn advertises `{ outputSchema: true, depthLimit: true, toolFilter: true, pers
| Key | Meaning |
|---|---|
| `providerName` | Registry name on `ctx.subagents` (default `spawn`). |
## Model Experience
### Child-agent request
**What the model sees**: The fresh child receives the standalone task content verbatim, inherits the parent model and workspace by default, and sees the global prompt with any configured child-scoped persona shadow. A tool filter removes global wire schemas, executable lookup, and Code Mode SDK bindings for that child but leaves independently registered guidance. It receives zero parent conversation messages; the filter is visibility/composition, not an authority grant inherited from the parent.
**Token effect**: The child pays for a new independent context and history; no parent-history tokens are duplicated. Persona changes this child's repeated prompt cost, while filtering changes its schema or generated SDK cost.
### Parent tool result, indirectly
**What the model sees**: Through `dsh-tool-subagent`, the parent receives only the child's final output or stop-reason error.
**Token effect**: Parent input grows by one data-dependent result retained until compaction.
## Known Limitations and Deferred Work
- **Runs expose no `sendMessage`/`resume`** — the optional runtime capabilities are absent on in-process runs.
- **Fresh means no parent transcript** — the child inherits cwd, lineage, model, and explicitly configured persona/tool restrictions, but none of the parent's conversation; use the fork provider when completed-turn context is required.

View File

@@ -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 child factory already provides it during setup,
// and adding it here would unnecessarily change this provider's apply timing.
export const inject = ['subagents']
/** Config: the registry name to register the provider under. */

View File

@@ -152,11 +152,8 @@ describe('dsh-subagent-spawn', () => {
})
it('rejects without publishing 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.
// An already-aborted signal emits no future event, so start must check it before listening and
// settle aborted without running the child. The empty model script proves no turn occurs.
const controller = new AbortController()
controller.abort()
const { ctx, parent } = await setup([])
@@ -165,10 +162,8 @@ describe('dsh-subagent-spawn', () => {
})
it('same-tick cancellation rejects start and prevents child publication', async () => {
// Regression: cancellation before publication used to set a flag but let the
// async factory publish a child anyway, so `started` fulfilled and lifecycle
// observers saw an agent for an attempt the caller had already cancelled.
// The empty script also proves no model turn can run.
// Same-tick cancellation must win before async factory publication: no child may become
// visible, `started` must not fulfill, and the empty script proves no model turn occurs.
const { ctx, parent } = await setup([])
const beforeAgents = ctx.agents.list().length
const beforeSessions = ctx.sessions.list().length

View File

@@ -38,3 +38,14 @@ A per-run isolated config directory for an external CLI child (the target of `CL
## Testing
`tests/subagent-subprocess.spec.ts`: the env scrub and config-dir helpers run against the real process env and real filesystem (the rm-failure path injects its rejection at the fs boundary — a real recursive-rm failure is not portably provokable, and root ignores permission bits); the exit waits and the dispose ladder run against a scriptable fake child, driving each escalation tier deterministically. The [ACP backend suite](../subagent-acp/README.md) exercises the same ladder against real subprocesses (EOF-cooperative, EOF-ignoring, and SIGTERM-trapping children) end to end.
## Model Experience
Indirectly, through process-based subagent backends, whose child composition is constrained by credential scrubbing and isolated config directories.
## Known Limitations and Deferred Work
- **The credential scrub is name-based** — only variables matching `KEY` / `SECRET` / `TOKEN` are removed; differently named secrets such as `PASSWORD` pass through unless the backend supplies a stricter environment.
- **Signals target the direct child only** — teardown relies on a cooperative CLI to reap its descendants before exit; a re-parented or independently detached grandchild can outlive the ladder.
- **Fresh config-dir cleanup is best-effort** — an `rm` failure leaves private state under the OS temp root rather than failing disposal.
- **Pinned config directories are wholly operator-owned** — the helper neither creates, validates, locks, nor removes them, so concurrent runs may share and race on that state.

View File

@@ -1,20 +1,8 @@
/**
* 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. This package
* registers no provider; consuming plugins own and validate every timing or path default.
* @module @deepseek-ai/dsh-subagent-subprocess
*/
@@ -52,11 +40,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 `error` event as a promise. Call in the same tick as
* `spawn()`; otherwise an early event can be unhandled and crash the parent.
* @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 +110,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, resolving only after exit: close stdin and allow
* cooperative flush, then send `SIGTERM`, then `SIGKILL` and await the forced exit.
*
* @param child - the child process to tear down.
* @param graces - the two grace periods, from the consuming plugin's Config.
@@ -141,10 +119,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. Close stdin and allow cooperative teardown and durable-state flush.
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 +148,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, independent of host CLI state. Without
* `pinnedPath`, creates a private temp directory and removes it best-effort; a pinned directory
* is returned unchanged and remains deployment-owned.
*
* @param prefix - the `mkdtemp` name prefix for a fresh dir (e.g.
* `dsh-subagent-codex-`); ignored when `pinnedPath` is set.
@@ -209,10 +177,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).
}
},
}

View File

@@ -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 real-passthrough except for one deterministic failure. Permission-based recursive-rm
// failures are not portable and disappear under root, so this is the sanctioned filesystem seam.
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return { ...actual, rm: vi.fn(actual.rm) }

View File

@@ -59,3 +59,12 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen
## Collection model
The current model-facing tool collects synchronously: it awaits the child result and disposes the run before returning. Background collection and polling remain outside this seam. See the [capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md) and `src/types.ts` for the complete contracts.
## Model Experience
Indirectly, through `dsh-tool-subagent`, which retains only a provider's data-dependent final output or exact `Error: no subagent provider registered for "<name>"`, `Error: subagent provider "<name>" does not support the "<capability>" capability`, and `Error: <message>` start failures in the parent while child working tokens remain child-only.
## Known Limitations and Deferred Work
- **The current consumer collects synchronously** — the model-facing tool starts a run and awaits `result`; steering (`sendMessage`) is part of the seam but intentionally unused, and background/poll/spill semantics are deferred to a future long-running-runtime design.
- **The lifecycle events are observe-only** — a run-affecting `subagent/end` continuation or decision surface is deferred until a consumer needs one.

View File

@@ -11,16 +11,12 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { StructuredOutputSchema, ToolRestriction } 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). These static flags cover features needed before a run exists; runtime
* capabilities such as steering and resume are optional {@link SubagentRun} methods whose presence
* is the capability.
*/
export interface SubagentCapabilities {
/** Honor {@link SubagentStartRequest.outputSchema} (structured final output). */
@@ -60,14 +56,9 @@ export interface SubagentStartRequest {
/** Per-child agent options (model and plugin-defined extension fields). */
readonly agentOptions?: AgentOptions
/**
* Optional structured-output schema — an object-rooted JSON Schema within the
* enforced subset (see `assertSupportedOutputSchema` in dsh-tools; a schema
* outside the subset is rejected loud at start). When set AND the provider's
* {@link SubagentCapabilities.outputSchema} is `true`, the child is driven to
* report a value matching this schema, surfaced as
* {@link SubagentResult.structured}. The schema must be plain host-realm JSON
* data — a caller holding foreign-realm data materializes it first.
* Requesting it against a provider that lacks the capability is rejected at start.
* Object-rooted JSON Schema within `assertSupportedOutputSchema`'s enforced subset. Start rejects
* unsupported schemas or providers without the capability. Data must be plain host-realm JSON;
* a successful child returns the matching value as {@link SubagentResult.structured}.
*/
readonly outputSchema?: StructuredOutputSchema
/**
@@ -136,14 +127,9 @@ export interface SubagentResult {
}
/**
* A live subagent run: a handle the consumer holds while a child executes.
* Returned by {@link SubagentProvider.start} (via the service) only after the
* child is ready. The consumer awaits {@link result} and MUST {@link dispose}
* on every path to cancel any remaining work and reach child quiescence.
*
* {@link sendMessage} and {@link resume} are OPTIONAL: a provider that supports
* the runtime capability defines the method; one that doesn't omits it. The
* presence of the method IS the capability — narrow before calling.
* Child handle returned only after readiness. Consumers await {@link result} and must always
* {@link dispose} to cancel remaining work and reach quiescence. Optional methods are runtime
* capability discovery; narrow their presence before calling.
*/
export interface SubagentRun {
/** The child agent's id (local in-process runs are already published in `ctx.agents`; remote transports need not publish locally). */
@@ -188,15 +174,9 @@ export interface SubagentProvider {
/** The start-time features this provider supports (see {@link SubagentCapabilities}). */
readonly capabilities: SubagentCapabilities
/**
* The provider's conversation-history descriptor: `true` when a child SEES the parent
* conversation (fork — the child is seeded with the parent's completed-turn
* prefix), `false` when it starts fresh (spawn, ACP). A DESCRIPTIVE fact,
* not a start-time capability: the service validates nothing against it —
* the model-facing consumer (`dsh-tool-subagent`) derives truthful tool
* wording from it, so a tool bound to a fork provider stops telling the
* model the child "does not see this conversation". This descriptor concerns
* conversation history only; it says nothing about tool registrations,
* injected services, or authority inheritance.
* Whether the child sees the parent's completed-turn prefix. This is descriptive, not a
* service-validated start capability: the model-facing tool derives truthful wording from it.
* It says nothing about tool registration, injected services, or authority inheritance.
*/
readonly inheritsParentContext: boolean
/**

View File

@@ -26,3 +26,41 @@ A non-`completed` stop reason becomes an `isError` tool result; partial child ou
| `maxDepth` | Absolute delegation-depth cap; requires provider `depthLimit` capability. |
`toolFilter` changes the child's visible global tool layer; it is not a parent-derived authority ceiling. See the [agent-scope security non-goal](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals).
## Model Experience
### Standalone-provider schema
**What the model sees**: While a fresh-context provider exists, the configured tool uses the generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent); the catalog also records how `toolName` changes the visible name.
**Token effect**: Fixed schema cost per parent request while mounted. Removing the provider removes the whole schema.
### Inherited-context-provider schema
**What the model sees**: Relative to the generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent), a provider that seeds completed turns replaces only the tool and `prompt` parameter descriptions with the text below; the shape and `description` parameter stay unchanged.
**Token effect**: Fixed schema cost per parent request while mounted. Exposing multiple providers adds one independently named schema per load.
#### Inherited-context-provider tool description
```markdown
Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.
```
#### Inherited-context-provider prompt description
```markdown
The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new.
```
### Tool-call history and result
**What the model sees**: The task description and full prompt remain in the parent assistant tool call. Success contains only the child's data-dependent final text. Other stop reasons become exactly `Error: subagent run was cancelled`, `Error: subagent run failed`, `Error: subagent run hit its token limit before finishing`, `Error: subagent declined the task`, or `Error: subagent run ended abnormally (<reason>)`; a call without an owning agent becomes `Error: subagent tool requires a calling agent (exec.agent was undefined)`. Intermediate child steps never enter the parent.
**Token effect**: Prompt and final output are data-dependent retained tokens. All child working context is paid in the child and omitted from the parent.
## Known Limitations and Deferred Work
- **Delegation blocks the parent turn** — synchronous collect only; background start and poll collection are deferred to the long-running-runtime redesign.
- **Duplicate `toolName` across waiting loads is detected late** (`TODO(subagent-dup-toolname)`) — two loads waiting on providers collide only when a provider arrives, and the throw rolls back the provider's fiber rather than the misconfigured tool's; config-time detection needs a cross-fiber registry of intended names.
- **Child policy is fixed per tool registration** — `model`, persona, tool filter, and depth cap come from this plugin load's config, not model-call arguments; exposing another policy requires another distinctly named tool.

View File

@@ -1,34 +1,11 @@
/**
* 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 conversation-history
* descriptor ({@link providerWording}): a fresh-conversation provider (spawn,
* ACP) gets the standalone-prompt wording, while a seeded-conversation provider
* (fork) tells the model the child already sees the conversation's completed
* turns. This descriptor says nothing about Cordis scope, services, tools, or
* authority. 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.
* Model-facing delegation tool bound by configuration to one provider; transport selection is not
* exposed in its `{ description, prompt }` schema. Provider lifecycle controls registration and
* re-derives conversation-history wording after reload, so load order is irrelevant.
*
* Execution synchronously awaits the child result and always disposes the run. Non-completed stop
* reasons become error results, while transport details remain behind `ctx.subagents`. Load this
* plugin more than once to expose multiple configured providers.
* @module @deepseek-ai/dsh-tool-subagent
*/
@@ -106,16 +83,8 @@ export const Config: z<Config> = z.object({
model: z.string(),
}).default(undefined as unknown as { provider: string; 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.
// Schemastery otherwise materializes omitted objects and nested arrays as `{ allow: [] }`, which
// silently means deny all. Preserve omission while retaining an explicit empty allow-list.
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[]),
@@ -291,7 +260,7 @@ export function apply(ctx: Context, config: Config): void {
if (present !== undefined) {
mount(present)
} else {
// Not an error: the backend's fiber may simply activate after this one.
// Not an error: the backend's fiber may 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`)