docs: reconcile hidden internals with prose standard

This commit is contained in:
Tianyi Cui
2026-07-14 23:38:53 +08:00
parent d8c76d3f65
commit ed3654da00
3 changed files with 31 additions and 95 deletions

View File

@@ -1,19 +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: credential-shaped env scrubbing
* ({@link buildChildEnv}), spawn-failure capture ({@link spawnFailure}),
* bounded child-exit waits inside 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
*/
@@ -50,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 `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.
@@ -123,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, 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.
@@ -139,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. 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.
@@ -171,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, 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.
@@ -207,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).
}
},
}

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 SubagentProvider.inheritsParentContext}): 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
*/
@@ -105,16 +82,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.
// 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[]),
@@ -290,7 +259,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`)