Files
deepseek-harness/packages/subagent/subagent-inprocess
Tianyi Cui 7c5133488a refactor(core): every registry register-method returns the exact effect disposer
The exact-disposer fix (5fbac8be B1) repaired agents.register but left the
same wrapper (return () => void dispose()) at seven sibling sites:
tools.register, tools.restrict, systemPrompt.section/tools/variable,
agents.setFactory, and subagents.registerProvider. A wrapper makes correct
composite usage unrepresentable — the exact disposer cannot be recovered, so
a generator effect yielding it leaves the inner effect disposing as a
CONCURRENT SIBLING on owner unload, silently reproducing B1's ordering
corruption. The exact disposer serves both usages (composite-nestable AND
fire-and-forget callable); all seven now return it, typed
() => Promise<void> | void, with the convention pinned by a discriminating
test: an async-link composite probe that passes with the exact disposer and
observes the sibling unregistration firing mid-drain with a wrapper.

Re-auditing also surfaced that B1 itself SHIPPED a full-lint failure: it
changed register()'s return type without updating cross-file consumers
(agent.spec.ts dispose() statements, tool-bash's disposer list), which the
staged-scoped pre-commit lint never saw — pnpm run lint was red at HEAD.
Those three sites and this change's own fallout are fixed together: tests
now await disposers (stronger — they observe the full unwind), sync
paths void them, and the two annotation sites carry the honest union type.
agents.register's README line had drifted the same way (B1 updated the
JSDoc, not the README) — all seven README signatures now match; services
catalog regenerated.
2026-07-09 13:05:44 +08:00
..

@deepseek-ai/dsh-subagent-inprocess

The shared in-process subagent run driver. A pure library (no provider, no registration) that the in-process backends — spawn (a fresh child) and fork (a child seeded with a prefix of the parent's log) — both build on. 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.

What it exports

startInProcessRun(ctx, request, options): SubagentRun

Runs a child as a child Agent on the same cordis context (ctx.agents):

  1. computes child depth = depthOf(parent) + 1; if request.maxDepth is set and exceeded, throws SubagentDepthError (the depthLimit capability); a request.outputSchema is asserted against the supported subset (assertSupportedOutputSchema from dsh-tools) and then snapshotted with structuredClone before any child exists — assertion first so a hostile value fails as OutputSchemaError (never a raw clone error), the snapshot so a post-start() caller mutation cannot drift the enforced schema;
  2. creates a child via ctx.agents.create with a fresh AgentId/SessionId, the parent's cwd + parentSession lineage, the optional options.seed (fork's completed-turn prefix; omitted for a fresh child), and agentOptions (the child inherits the parent's model by default — a child with no model can't run — overridable via request.agentOptions.model; the deployment persona needs no inheritance — it is a context-wide prompt section);
  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() delegates to AgentHandle.dispose() (stop loop → await quiescence → remove session); cancel() cancels the child's in-flight turn. A cancel landing before any turn/end (the pre-turn window) still settles aborted, honoring the cancel contract rather than the generic no-turn error.

InProcessRunOptions

{ providerName: string; seed?: SessionEvent[] } — the per-backend inputs: the provider name (for error context) and the optional child-session seed.

Structured output (package-internal runtime)

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):

  • 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 keyed by its call id;
  • 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 system-prompt/assemble re-assert (prepend: true = outermost): whatever downstream listeners mutate or replace, the child's assembly always carries its capture tool and instruction — the loop logs the rendered assembly as the step's request/header, so the demand is reconstructable log state;
  • a scoped tools/post-execute COMMIT (prepend: true): the staged value becomes the run's result only when the final decision accepts THE SAME CALL that staged it — call-keyed, so a stale stage orphaned by an outer short-circuiting listener is dropped, never promoted on a later call's acceptance;
  • a scoped tools/pre-execute deny for any call arriving after the capture — terminal means terminal WITHIN the step;
  • a scoped agent/turn-continuation veto (prepend: true) stopping the child's turn once its output is captured, so a successful capture doesn't buy a wasted extra model step.

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.