From 7aabd2a3dfb673f756781fd7879394682952d82f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 22 Jun 2026 05:58:40 +0800 Subject: [PATCH 1/5] Add in-process subagent backends: spawn (fresh) and fork (seeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second PR of the subagent seam: the two in-process backends that run a child agent on the same cordis context, reusing the agent factory's quiescent AgentHandle teardown. Both register on ctx.subagents (PR1's named-provider registry) and share one run driver. - dsh-subagent-spawn: a FRESH child via ctx.agents.create — own session, the parent's model by default (overridable), zero inherited conversation. Also exports the shared in-process run driver (startInProcessRun): mint ids, stamp cwd/parentSession-lineage/depth, drive the one-shot (send → whenIdle), read the last assistant/message + turn/end reason, dispose to quiescence. - dsh-subagent-fork: a child SEEDED with the parent's balanced completed-turn prefix (the log up to and including its last turn/end), so the child inherits context. The in-flight unbalanced turn is excluded — a raw seed would fail the invariants replay. Proven: a regression test goes red if the boundary seeds the open turn. - Seam extension: CreateAgentOptions.seed, threaded through AgentLoop.createAgent → ctx.sessions.prepare({ seed }) (the primitive resume already used). This is the fork-lineage path the TODO(sub-agents) markers anticipated. - Depth: a merge-extensible AgentOptions.subagentDepth (0 top-level, parent+1 for a child); the depthLimit capability refuses a spawn past request.maxDepth. Tests: real-loop unit tests for both backends (mock MODEL only, real loop + invariants), a multi-subagent test (one parent drives a fork AND a spawn child then keeps working), and a with-key e2e (a real parent delegates via the `subagent` tool to a real child that writes a file on disk — world-verified). 100% per-file coverage. The coding-agent demo wires the spawn backend + tool. Snapshot coverage of nested agents is deferred to a stacked follow-up (TODO(subagent-snapshots)): dsh-llm-replay is a single global positional cursor that cannot route calls to a parent vs. a child on one context. Recorded in the RFC's deferrals and a new AGENTS.md rule: designing a subsystem must design its test infrastructure END TO END up front, verifying the snapshot/e2e harness can express the new shape — a gap this plan hit. --- AGENTS.md | 1 + docs/cordis-catalog/events-and-services.md | 2 +- docs/core-data-structures/subagent.md | 7 + docs/module-graph.md | 10 + .../2026-06-21-subagent-capability-seam.md | 1 + examples/coding-agent/cordis.yml | 33 ++- knip.json | 4 + packages/README.md | 4 + packages/core/agent-loop/src/index.ts | 14 +- packages/core/agent/src/index.ts | 13 +- packages/subagent/README.md | 4 +- packages/subagent/subagent-fork/README.md | 23 ++ packages/subagent/subagent-fork/package.json | 45 ++++ packages/subagent/subagent-fork/src/index.ts | 79 ++++++ .../tests/multi-subagent.spec.ts | 99 ++++++++ .../subagent-fork/tests/subagent-fork.spec.ts | 161 ++++++++++++ packages/subagent/subagent-fork/tsconfig.json | 33 +++ packages/subagent/subagent-spawn/README.md | 29 +++ packages/subagent/subagent-spawn/package.json | 48 ++++ .../subagent/subagent-spawn/src/in-process.ts | 164 ++++++++++++ packages/subagent/subagent-spawn/src/index.ts | 57 +++++ .../subagent/subagent-spawn/tests/harness.ts | 49 ++++ .../subagent-spawn/tests/spawn.e2e.ts | 54 ++++ .../tests/subagent-spawn.spec.ts | 239 ++++++++++++++++++ .../subagent/subagent-spawn/tsconfig.json | 33 +++ packages/support/llm-replay/src/index.ts | 10 + pnpm-lock.yaml | 89 +++++++ tsconfig.build.json | 4 +- 28 files changed, 1296 insertions(+), 13 deletions(-) create mode 100644 packages/subagent/subagent-fork/README.md create mode 100644 packages/subagent/subagent-fork/package.json create mode 100644 packages/subagent/subagent-fork/src/index.ts create mode 100644 packages/subagent/subagent-fork/tests/multi-subagent.spec.ts create mode 100644 packages/subagent/subagent-fork/tests/subagent-fork.spec.ts create mode 100644 packages/subagent/subagent-fork/tsconfig.json create mode 100644 packages/subagent/subagent-spawn/README.md create mode 100644 packages/subagent/subagent-spawn/package.json create mode 100644 packages/subagent/subagent-spawn/src/in-process.ts create mode 100644 packages/subagent/subagent-spawn/src/index.ts create mode 100644 packages/subagent/subagent-spawn/tests/harness.ts create mode 100644 packages/subagent/subagent-spawn/tests/spawn.e2e.ts create mode 100644 packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts create mode 100644 packages/subagent/subagent-spawn/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index a68e13ccef..28d66fa686 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,6 +207,7 @@ Dev/test/demo run **unbuilt** via tsx + the `paths` map in the root `tsconfig.js - **Tests**: vitest, colocated under `packages///tests/*.spec.ts`. Every registry needs an HMR-safety test (dispose the contributing fiber, assert cleanup). **Excessive tests are welcome** — when in doubt, write the test; err on the side of covering edge cases, error paths, event ordering, and concurrency races even if they seem unlikely. Review findings get regression tests (see `packages/core/agent-loop/tests/review-fixes.spec.ts`). The same generosity applies to **real-API (with-key) e2e tests — inference is cheap here (we are DeepSeek), so do not ration them**: cover the agent's real flows (a real prompt that writes a file, multi-turn, tool use, cancellation) and run them frequently while developing, especially cheap **smoke tests** that boot the real example and check the world. A green mock/no-key suite proves the plumbing, not the product — the with-key smoke test is what catches "green units, broken product". See § Secrets / .env for the with-key policy and why self-skip is a CI accommodation, not a verdict that real-API tests are expensive. - **Prefer the REAL implementation over a mock/stand-in in tests.** When the genuine collaborator is available in the repo, wire it up instead of hand-rolling a fake — a test that registers an inline `defineTool({ name: 'bash', … })` to stand in for `dsh-tool-bash` proves the *bridge* moves bytes but not that the *shipping tool* renders the way the test asserts; the two drift and the test passes while the product is wrong. Mock only the genuinely expensive/non-deterministic boundary (the LLM adapter, the network, the clock) and keep everything downstream real: a bridge tool-call test runs the scripted mock MODEL but the REAL tool + REAL executor (e.g. `makeBridgeHarness({ withBash: true })` plugs `dsh-bash-local` + `dsh-tool-bash` and runs an actual `echo`), so it verifies the actual `presentCall`/`presentResult` an editor sees. This is the unit-test echo of "verify the world, not a synthetic stand-in" (see § Defensive patterns) — a fake you wrote will agree with whatever you assumed; the real thing won't. - **A change that affects the editor-facing transcript or end-to-end agent UX needs a snapshot test (or an explicit note in the PR why none applies).** The snapshot tier (`examples/*/tests/**/*.snapshot.ts`, `pnpm run test:snapshot`) boots the real example subprocess, replays a recorded session JSONL deterministically (keyless), and diffs the normalized stdout transcript + re-persisted session log against committed goldens — the full-transcript regression net that mock-level unit tests structurally cannot be (it is what catches a bridge-translation or loop-structure regression that leaves every unit green). When you change the ACP bridge, the agent loop's observable output, tool presentation, or anything an editor renders, add or update a scenario under `examples/acp-agent/tests/snapshots/` and re-record with `pnpm run test:snapshot:record`. Reviewing the golden diff is part of the review. The rule is scoped to transcript/UX-affecting changes — a pure internal refactor with no observable-output change does not need one, but say so. See [docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md](docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md). +- **Designing a new subsystem includes designing its test infrastructure — END TO END, up front, as part of the same plan.** When you introduce a new capability seam, a new agent-lifecycle shape, or anything that produces an observable transcript (a new tool family, a subagent transport, a new UI surface), the plan must name how it will be covered at EVERY tier it touches — unit, real-API e2e, AND the full-transcript snapshot tier — and, critically, must check that the existing test infrastructure can actually express that coverage. Do not assume a snapshot/e2e harness built for one shape (e.g. a single top-level ACP session) transparently supports a new shape (e.g. a parent agent driving nested child agents): verify it, and if it cannot, the harness extension is in-scope work to plan and schedule, not a detail to discover mid-implementation. This rule exists because a real plan under-scoped exactly this: the subagent backends were planned with unit + e2e coverage but the snapshot tier turned out to assume one session per process (`dsh-llm-replay`'s single positional cursor, single-file harvest), so nested-agent snapshot coverage became unplanned net-new infrastructure (`TODO(subagent-snapshots)`). The cost of finding that during design is a paragraph; the cost of finding it mid-build is a re-plan. When the harness gap is large enough to be its own reviewable unit, schedule it as a dedicated stacked follow-up with its own RFC — but SAY SO in the originating plan, with the gap named, rather than letting it surface as a surprise. ## Defensive patterns (hard-won) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 8bb2b6b398..36edfae505 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -332,7 +332,7 @@ list(): Agent[] Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:105`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:116`](../../packages/core/agent/src/index.ts) ### `ctx.bash` — `BashExecutor` (abstract seam) diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 5ae83b40fa..ec58de91bf 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -86,3 +86,10 @@ interface SubagentProvider { ``` The service (`ctx.subagents`) emits `subagent/start` when a run begins and `subagent/end` when it settles (see the [events catalog](../cordis-catalog/events-and-services.md)). Both emits contain a thrown listener **per listener** (logged, never propagated): one bad subscriber can neither strand a live run, surface as an unhandled rejection on the detached settle hook, nor starve the listeners registered after it. + +## In-process backends: depth and seed + +The two in-process backends ([dsh-subagent-spawn](../../packages/subagent/subagent-spawn) fresh, [dsh-subagent-fork](../../packages/subagent/subagent-fork) seeded) run the child as a child `Agent` on the same context via `ctx.agents.create`. Two pieces of vocabulary ride on the existing agent/session types rather than new core types: + +- **Delegation depth** is a merge-extensible `AgentOptions.subagentDepth` field (`0` for a top-level agent, parent + 1 for a child). The seam owns it — the loop neither sets nor reads it — so a nested spawn reads its parent's depth from `parent.options.subagentDepth` and the `depthLimit` capability caps the tree by refusing a child whose depth would exceed `request.maxDepth`. +- **Fork seeding** uses `CreateAgentOptions.seed` (a `SessionEvent[]` prefix threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`, the same primitive `resume` uses). The fork backend passes a *balanced completed-turn prefix* of the parent's log — the parent's events up to and including its last `turn/end` — so the seed is contiguous-from-0 and the [invariants](../../packages/support/invariants) replay accepts it (the in-flight, unbalanced turn is excluded). diff --git a/docs/module-graph.md b/docs/module-graph.md index b8d582838f..0724130ced 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -63,6 +63,10 @@ graph TD subagent-mock --> agent subagent-mock --> llm subagent-mock --> subagent + subagent-spawn --> agent + subagent-spawn --> llm + subagent-spawn --> session + subagent-spawn --> subagent tool-subagent --> agent tool-subagent --> llm tool-subagent --> subagent @@ -75,6 +79,10 @@ graph TD stdio-agent --> session stdio-agent --> session-persistence-jsonl stdio-agent --> ui-stdio + subagent-fork --> agent + subagent-fork --> session + subagent-fork --> subagent + subagent-fork --> subagent-spawn ``` | Package | Depends on | @@ -101,6 +109,8 @@ graph TD | `tool-bash` | `agent`, `bash`, `llm`, `tools` | | `agent-core` | `agent`, `agent-loop`, `invariants`, `llm`, `session`, `system-prompt`, `tool-bash`, `tools` | | `subagent-mock` | `agent`, `llm`, `subagent` | +| `subagent-spawn` | `agent`, `llm`, `session`, `subagent` | | `tool-subagent` | `agent`, `llm`, `subagent`, `tools` | | `acp-agent` | `acp`, `agent-core`, `session-persistence-jsonl` | | `stdio-agent` | `agent`, `agent-core`, `session`, `session-persistence-jsonl`, `ui-stdio` | +| `subagent-fork` | `agent`, `session`, `subagent`, `subagent-spawn` | diff --git a/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md b/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md index 553077d35d..02c4fa36b4 100644 --- a/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md +++ b/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md @@ -70,3 +70,4 @@ The `dsh-tool-subagent` consumer awaits `run.result` and returns the child's fin - **Blocking the parent turn.** Synchronous collect holds the parent's `runStep` open for the child's full duration. This is acceptable for the first cut; **background / poll / spill semantics are deferred to a future redesign that unifies long-running-tool handling across subagents AND bash** (a sub-agent and a long `bash` background task pose the same "the model started something slow, how does it collect later" problem, and should share one mechanism rather than each inventing its own). - **Live progress.** This cut surfaces only lifecycle + final result; a per-chunk child→parent update stream is deferred with the background redesign. - **ACP client surface.** Proxying `fs`/`terminal` from the ACP child back to the parent (a shared-workspace mode) is future work; the first cut advertises neither, so the child self-serves in its own process. +- **Snapshot coverage of nested agents.** The snapshot tier (`pnpm run test:snapshot`) replays a recorded session through `dsh-llm-replay`, whose dispatch is a single GLOBAL positional cursor (the Nth `llm/stream` call serves the Nth recorded entry) and whose harness harvests a single session log file. A subagent runs as a *second* agent with its own session log, so a parent→child scenario needs per-session-keyed replay (or a call-ordered merge of both logs, sound because subagent execution is strictly nested/non-concurrent — the parent blocks on the child) plus harvest-all-logs and plural-session-id plumbing in the harness. This is self-contained infrastructure orthogonal to the backends, so it lands as a **dedicated stacked follow-up** rather than in the in-process-backends PR. Until it lands, in-process subagents are covered by real-loop unit tests (a parent driving a fork AND a spawn child) and a with-key e2e (a parent delegating to a child that writes a file), not by the snapshot transcript tier. Tracked by `TODO(subagent-snapshots)`. diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index 497aa8896a..c8371d3cba 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -48,12 +48,35 @@ systemPrompt: | You are coding-agent, a CLI coding assistant. - Your only tools are bash (plus bash_output/bash_kill for background - tasks). Do ALL file operations through bash: read with cat/sed/head, - search with grep, write with heredocs (cat <<'EOF' > file), edit - with sed or a rewrite. Each bash call runs in a fresh shell — pass - workdir instead of cd, and never rely on shell state between calls. + Your tools are bash (plus bash_output/bash_kill for background + tasks) and subagent. Do ALL file operations through bash: read with + cat/sed/head, search with grep, write with heredocs (cat <<'EOF' > + file), edit with sed or a rewrite. Each bash call runs in a fresh + shell — pass workdir instead of cd, and never rely on shell state + between calls. + + Use the subagent tool to delegate a focused, self-contained subtask + to a fresh child agent (it works in its own context and returns only + its final result) — give it a complete, standalone instruction. Check the [exit code: N] marker on every command; investigate failures before moving on. Verify your work by running the code or tests. Keep answers brief and factual. + +# The subagent seam + an in-process spawn backend + the model-facing `subagent` +# tool, as leaf entries after the app (which provides ctx.agents/ctx.tools). The +# tool is bound to the `spawn` backend: a delegated task runs as a fresh child +# agent on this same process. (fork is available too — load dsh-subagent-fork +# and a second dsh-tool-subagent bound to it with a distinct toolName.) +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subagent-spawn + name: '@deepseek-ai/dsh-subagent-spawn' + config: + providerName: spawn + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn diff --git a/knip.json b/knip.json index b3ce7c1d4b..aaf0e105d3 100644 --- a/knip.json +++ b/knip.json @@ -36,6 +36,10 @@ "packages/ui/stdio-agent": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] + }, + "packages/subagent/subagent-spawn": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] } } } diff --git a/packages/README.md b/packages/README.md index c2cba3461b..7cf32781bf 100644 --- a/packages/README.md +++ b/packages/README.md @@ -40,6 +40,8 @@ dsh-ui-stdio ← dsh-agent, dsh-llm, dsh-session (stdio readline UI plugin) dsh-llm-replay ← dsh-llm, dsh-session (record/replay adapter for keyless snapshot tests) dsh-subagent ← dsh-agent, dsh-llm, dsh-tools (abstract subagent provider-registry seam) dsh-subagent-mock ← dsh-subagent (scripted provider for tests) +dsh-subagent-spawn ← dsh-subagent, dsh-agent, dsh-session, dsh-llm (in-process fresh child + shared run driver) +dsh-subagent-fork ← dsh-subagent-spawn, dsh-agent, dsh-session (in-process child seeded from parent log) dsh-tool-subagent ← dsh-subagent, dsh-tools, dsh-agent (model-facing delegation tool) dsh-agent-core ← timer, dsh-llm, dsh-session, dsh-system-prompt, dsh-tools, dsh-agent, dsh-invariants, dsh-tool-bash, dsh-agent-loop (the providerless spine, as one bundle plugin) dsh-stdio-agent ← dsh-agent-core, dsh-ui-stdio, dsh-session-persistence-jsonl, dsh-agent, dsh-session (stdio chat APP + bin) @@ -74,6 +76,8 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop | `ui-stdio/` | `support` | Minimal stdio (readline) UI plugin: renders `agent/*` events, feeds stdin lines to the agent | (drives `ctx.agents`) | | `llm-replay/` | `support` | Record/replay adapter: short-circuits `llm/stream` with chunks from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) | | `subagent/` | `subagent` | Abstract subagent seam: named-provider registry for delegating to child agents | `ctx.subagents` | +| `subagent-spawn/` | `subagent` | In-process backend: a fresh child agent (+ the shared in-process run driver) | (registers on `ctx.subagents`) | +| `subagent-fork/` | `subagent` | In-process backend: a child agent seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) | | `subagent-mock/` | `support` | Scripted `SubagentProvider` for testing the seam through the real load path | (registers on `ctx.subagents`) | | `tool-subagent/` | `subagent` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | | `brand/` | `util` | Type-only `Branded` nominal-typing primitive (no runtime code, no harness deps) | (none — type-only) | diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 90d641eeeb..3179674366 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -140,16 +140,22 @@ export class AgentLoop extends Service implements AgentFactory { /** * Programmatic factory create ({@link AgentFactory}): an agent on a * caller-supplied `sessionId` (NOT `${id}-session`), with optional session - * metadata (validated `cwd`, lineage). The ACP bridge uses this so the - * client-generated session id becomes the live/persisted session id. Returns - * an {@link AgentHandle} the owner disposes to tear down exactly this agent. + * metadata (validated `cwd`, lineage) and an optional `seed` event prefix. The + * ACP bridge uses this so the client-generated session id becomes the + * live/persisted session id; the in-process FORK subagent backend passes a + * `seed` (a balanced completed-turn prefix of the parent's log) so the child + * starts with the parent's context. Returns an {@link AgentHandle} the owner + * disposes to tear down exactly this agent. */ createAgent(options: CreateAgentOptions): AgentHandle { // Check the agent id BEFORE preparing the session: register() would reject a // duplicate id only AFTER the session enters the store, leaving an orphaned // live session (and lazy persistence state) that blocks reuse of that id. this.assertAgentIdFree(options.agentId) - const session = this.ctx.sessions.prepare(options.sessionId, { meta: options.meta ?? {} }) + const session = this.ctx.sessions.prepare(options.sessionId, { + ...options.seed !== undefined ? { seed: options.seed } : {}, + meta: options.meta ?? {}, + }) return this.startOwned(options.agentId, options.agentOptions ?? {}, session) } diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 158946178c..1f2984a148 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -6,7 +6,7 @@ */ import { Context, Service } from 'cordis' -import type { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type { Agent, AgentId, AgentOptions } from './types.ts' export * from './types.ts' @@ -37,6 +37,17 @@ export interface CreateAgentOptions { * excluded — a factory caller never sets it). */ meta?: { cwd?: string; parentSession?: SessionId } + /** + * Seed events to reconstruct the child session's log from (the fork lineage + * primitive). When present, the factory creates the session with this event + * prefix so `deriveMessages()`/`lastTurnNumber` continue from it — used by the + * in-process FORK subagent backend to seed a child with a balanced + * completed-turn prefix of the parent's log. The prefix MUST be contiguous + * from seq 0 and balanced (no open turn/step, no dangling tool-call), or the + * session constructor (and the dev-mode invariants replay) reject it. Absent + * for a fresh (spawn) child. + */ + seed?: SessionEvent[] /** Per-agent options (model, system prompt). */ agentOptions?: AgentOptions } diff --git a/packages/subagent/README.md b/packages/subagent/README.md index 0fab2c610e..582172dfd1 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -5,8 +5,10 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](.. | Package | Role | ctx key | |---|---|---| | `subagent/` | Abstract subagent seam: named-provider registry + vocabulary | `ctx.subagents` | +| `subagent-spawn/` | In-process backend: a fresh child agent (+ the shared run driver) | (registers on `ctx.subagents`) | +| `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) | | `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | -The interface lives at `subagent/subagent/`. Provider implementations live in their own packages — the in-process `dsh-subagent-spawn` / `dsh-subagent-fork` and the out-of-process `dsh-subagent-acp` — plus the test-only `dsh-subagent-mock` in [support](../support/README.md). All **product** packages except the mock. +The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends ship here; the out-of-process `dsh-subagent-acp` and the test-only `dsh-subagent-mock` (in [support](../support/README.md)) are separate. All **product** packages except the mock. The proposal and design rationale: [docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md](../../docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md). diff --git a/packages/subagent/subagent-fork/README.md b/packages/subagent/subagent-fork/README.md new file mode 100644 index 0000000000..c691d56355 --- /dev/null +++ b/packages/subagent/subagent-fork/README.md @@ -0,0 +1,23 @@ +# @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 seed boundary (the crux) + +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. + +## Capabilities + +`{ outputSchema: false, depthLimit: true, toolFilter: false }` — identical to spawn (the depth/model/output behavior is the shared driver's). + +## 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. diff --git a/packages/subagent/subagent-fork/package.json b/packages/subagent/subagent-fork/package.json new file mode 100644 index 0000000000..44027b4b0c --- /dev/null +++ b/packages/subagent/subagent-fork/package.json @@ -0,0 +1,45 @@ +{ + "name": "@deepseek-ai/dsh-subagent-fork", + "description": "In-process fork subagent backend: runs a child agent seeded with a prefix of the parent's log", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-subagent": "^0.0.1", + "@deepseek-ai/dsh-subagent-spawn": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-spawn": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@cordisjs/plugin-loader": "^1.0.0-rc.4", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts new file mode 100644 index 0000000000..6c730e225e --- /dev/null +++ b/packages/subagent/subagent-fork/src/index.ts @@ -0,0 +1,79 @@ +/** + * 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. Shares the run driver with + * `@deepseek-ai/dsh-subagent-spawn`; the only difference is the seed. + * + * 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. + * + * @module @deepseek-ai/dsh-subagent-fork + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import { startInProcessRun } from '@deepseek-ai/dsh-subagent-spawn' + +export const name = 'subagent-fork' +export const inject = ['subagents', 'agents'] + +/** Config: the registry name to register the provider under. */ +export interface Config { + /** Provider name on `ctx.subagents` (default `fork`). */ + providerName: string +} + +export const Config: z = z.object({ + providerName: z.string().default('fork'), +}) + +/** + * 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. + */ +export function completedTurnPrefix(parent: Agent): SessionEvent[] { + const events = parent.session.events + const lastEnd = events.findLast(e => e.type === 'turn/end') + if (lastEnd === undefined) return [] + // seq === array index (the append contract), so slice up to and including it. + return events.slice(0, lastEnd.seq + 1) +} + +/** + * The fork provider. Supports `depthLimit`; NOT `outputSchema`/`toolFilter` this + * cut (the service rejects a request needing either before `start` runs). + */ +class ForkProvider implements SubagentProvider { + readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: true, toolFilter: false } + + constructor(readonly name: string, private readonly ctx: Context) {} + + start(request: SubagentStartRequest) { + const seed = completedTurnPrefix(request.parent) + return startInProcessRun(this.ctx, request, { + providerName: this.name, + // Only pass a seed when there's a completed turn to inherit; an empty seed + // is equivalent to a fresh child, so omit it to keep the session unseeded. + ...seed.length > 0 ? { seed } : {}, + }) + } +} + +export function apply(ctx: Context, config: Config): void { + ctx.subagents.registerProvider(new ForkProvider(config.providerName, ctx)) +} diff --git a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts new file mode 100644 index 0000000000..1f932fbaf9 --- /dev/null +++ b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import * as Invariants from '@deepseek-ai/dsh-invariants' +import SubagentService from '@deepseek-ai/dsh-subagent' +import * as Spawn from '@deepseek-ai/dsh-subagent-spawn' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import * as fork from '../src/index.ts' + +type Script = ConstructorParameters[0] + +/** + * The two in-process backends coexist on one context: the SAME parent agent + * delegates to a `spawn` child (fresh) and a `fork` child (seeded with its log), + * and keeps working itself. This is the multi-provider coexistence the seam + * exists for — the named registry lets one runtime hold both transports. + */ +async function setup(script: Script) { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(Invariants) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + await ctx.plugin(Spawn, { providerName: 'spawn' }) + await ctx.plugin(fork, { providerName: 'fork' }) + ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) + const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) + return { ctx, parent } +} + +function text(blocks: { type: string; text?: string }[]): string { + return blocks.filter(b => b.type === 'text').map(b => b.text).join('') +} + +describe('multi-subagent coexistence (spawn + fork on one context)', () => { + it('both providers register and coexist', async () => { + const { ctx } = await setup([]) + expect(ctx.subagents.list().sort()).toEqual(['fork', 'spawn']) + }) + + it('the same parent drives a spawn child AND a fork child, then keeps working', async () => { + // Script order: parent turn 1, spawn child, fork child, parent turn 2. + const { ctx, parent } = await setup([ + textResponse('parent turn one'), + textResponse('spawn child reply'), + textResponse('fork child reply'), + textResponse('parent turn two'), + ]) + + // Parent does one real turn first, so the fork has a completed turn to seed. + parent.send([{ type: 'text', text: 'parent q1' }]) + await parent.whenIdle() + const parentPrefixLen = parent.session.events.length + + // Delegate to a fresh spawn child. + const spawnRun = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'spawn task' }], parent }) + const spawnResult = await spawnRun.result + expect(spawnResult.stopReason).toBe('completed') + expect(text(spawnResult.output)).toBe('spawn child reply') + + // Delegate to a fork child (seeded with the parent's turn-1 prefix). + const forkRun = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'fork task' }], parent }) + const forkResult = await forkRun.result + expect(forkResult.stopReason).toBe('completed') + expect(text(forkResult.output)).toBe('fork child reply') + + // The two children are distinct sessions, both lineage-stamped to the parent. + const spawnChild = ctx.agents.get(spawnRun.id)! + const forkChild = ctx.agents.get(forkRun.id)! + expect(spawnChild.session.header.id).not.toBe(forkChild.session.header.id) + expect(spawnChild.session.header.parentSession).toBe(parent.session.header.id) + expect(forkChild.session.header.parentSession).toBe(parent.session.header.id) + // The fork child inherited the parent's prefix; the spawn child did not. + expect(forkChild.session.events.slice(0, parentPrefixLen).some(e => e.type === 'user/message')).toBe(true) + + await spawnRun.dispose() + await forkRun.dispose() + + // The parent is unaffected and keeps working after both delegations. + parent.send([{ type: 'text', text: 'parent q2' }]) + await parent.whenIdle() + const lastParentMessage = parent.session.events.findLast(e => e.type === 'assistant/message') + expect(lastParentMessage?.type === 'assistant/message' && text(lastParentMessage.data.content)).toBe('parent turn two') + // The parent's OWN log never recorded the children's internal steps — its + // only subagent-related entries would be tool/call+tool/result IF it had + // used the tool, but here we called the service directly, so the parent log + // is purely its own two turns. + expect(parent.session.events.filter(e => e.type === 'turn/end')).toHaveLength(2) + }) +}) diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts new file mode 100644 index 0000000000..d550101cb9 --- /dev/null +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import * as Invariants from '@deepseek-ai/dsh-invariants' +import SubagentService from '@deepseek-ai/dsh-subagent' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import * as fork from '../src/index.ts' +import { completedTurnPrefix } from '../src/index.ts' + +type Script = ConstructorParameters[0] + +/** + * Drives the REAL fork backend with a real loop + scripted mock MODEL + the + * real dsh-invariants plugin. The invariants plugin re-replays a seeded child + * log on `session/created` (its freeze-check), so a malformed (unbalanced) fork + * seed makes these tests THROW — that is the regression guard for the + * completed-turn-prefix boundary. + */ +async function setup(script: Script) { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(Invariants) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + await ctx.plugin(fork, { providerName: 'fork' }) + ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) + const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) + return { ctx, parent } +} + +function text(blocks: { type: string; text?: string }[]): string { + return blocks.filter(b => b.type === 'text').map(b => b.text).join('') +} + +describe('completedTurnPrefix', () => { + it('returns an empty prefix for a parent that has never completed a turn', async () => { + const { parent } = await setup([]) + expect(completedTurnPrefix(parent)).toEqual([]) + }) + + it('returns the balanced prefix up to and including the last turn/end', async () => { + const { parent } = await setup([textResponse('first'), textResponse('second')]) + parent.send([{ type: 'text', text: 'q1' }]) + await parent.whenIdle() + parent.send([{ type: 'text', text: 'q2' }]) + await parent.whenIdle() + + const prefix = completedTurnPrefix(parent) + // Ends exactly at the last turn/end; seq is contiguous from 0. + expect(prefix.at(-1)?.type).toBe('turn/end') + expect(prefix.map(e => e.seq)).toEqual(prefix.map((_, i) => i)) + // Both completed turns are present. + expect(prefix.filter(e => e.type === 'turn/end')).toHaveLength(2) + }) +}) + +describe('dsh-subagent-fork', () => { + it('forks an UNSEEDED (fresh) child when the parent has no completed turn', async () => { + // The parent has never completed a turn → empty prefix → the provider omits + // the seed → the child runs fresh. Exercises the `seed.length > 0` false arm. + const { ctx, parent } = await setup([textResponse('fresh child')]) + expect(completedTurnPrefix(parent)).toEqual([]) + const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child q' }], parent }) + const result = await run.result + expect(result.stopReason).toBe('completed') + expect(text(result.output)).toBe('fresh child') + const child = ctx.agents.get(run.id)! + // Only the child's own turn — no seeded parent turns. + expect(child.session.events.filter(e => e.type === 'turn/end')).toHaveLength(1) + await run.dispose() + }) + + it('seeds the child with the parent\'s completed-turn prefix (child inherits context)', async () => { + // Parent runs one turn, then we fork. The child's seeded log should contain + // the parent's first turn, and the child should run its own new turn on top. + const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')]) + parent.send([{ type: 'text', text: 'parent question' }]) + await parent.whenIdle() + const parentPrefixLen = parent.session.events.length + + const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child question' }], parent }) + const result = await run.result + expect(result.stopReason).toBe('completed') + expect(text(result.output)).toBe('child answer') + + const child = ctx.agents.get(run.id)! + // The child's log STARTS with the parent's prefix (seeded), then its own turn. + expect(child.session.events.length).toBeGreaterThan(parentPrefixLen) + // The seeded prefix carried the parent's user message. + const seededUser = child.session.events.slice(0, parentPrefixLen).find(e => e.type === 'user/message') + expect(seededUser).toBeDefined() + // Lineage stamped. + expect(child.session.header.parentSession).toBe(parent.session.header.id) + await run.dispose() + }) + + 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. + const { ctx, parent } = await setup([textResponse('done'), 'hang', textResponse('child')]) + parent.send([{ type: 'text', text: 'q1' }]) + await parent.whenIdle() + // Start a second turn that hangs (open turn/start + open step, never ends). + parent.send([{ type: 'text', text: 'q2' }]) + await new Promise(r => setTimeout(r, 20)) // let the hanging turn open + + // Forking now must NOT throw (the open second turn is excluded from the seed). + const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child q' }], parent }) + const result = await run.result + expect(result.stopReason).toBe('completed') + expect(text(result.output)).toBe('child') + + const child = ctx.agents.get(run.id)! + // The child's seed has exactly the ONE completed parent turn (the open one excluded). + const seedTurnEnds = child.session.events.filter(e => e.type === 'turn/end') + // 1 from the seeded parent turn + 1 from the child's own completed turn. + expect(seedTurnEnds.length).toBe(2) + + parent.cancel() + await run.dispose() + }) + + it('advertises depthLimit but not outputSchema/toolFilter', async () => { + const { ctx } = await setup([]) + expect(ctx.subagents.getProvider('fork')!.capabilities).toEqual({ outputSchema: false, depthLimit: true, toolFilter: false }) + }) + + it('unregisters the provider when its fiber is disposed (HMR safety)', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(AgentRegistry) + const fiber = await ctx.plugin(fork, { providerName: 'fork' }) + expect(ctx.subagents.list()).toEqual(['fork']) + await fiber.dispose() + expect(ctx.subagents.list()).toEqual([]) + }) + + it('has the namespace-plugin export shape (no stray default)', () => { + expect('default' in fork).toBe(false) + expect(fork.name).toBe('subagent-fork') + expect(fork.inject).toEqual(['subagents', 'agents']) + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(fork) as Record + expect(unwrapped).toBe(fork) + expect(unwrapped.name).toBe('subagent-fork') + expect(unwrapped.inject).toEqual(['subagents', 'agents']) + expect(typeof unwrapped.apply).toBe('function') + }) +}) diff --git a/packages/subagent/subagent-fork/tsconfig.json b/packages/subagent/subagent-fork/tsconfig.json new file mode 100644 index 0000000000..d05e0f6081 --- /dev/null +++ b/packages/subagent/subagent-fork/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, + { + "path": "../subagent" + }, + { + "path": "../subagent-spawn" + } + ] +} diff --git a/packages/subagent/subagent-spawn/README.md b/packages/subagent/subagent-spawn/README.md new file mode 100644 index 0000000000..4e6e22f68d --- /dev/null +++ b/packages/subagent/subagent-spawn/README.md @@ -0,0 +1,29 @@ +# @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. + +It also exports the **shared in-process run driver** (`startInProcessRun`) that the [fork](../subagent-fork/README.md) backend builds on — spawn and fork differ only in the session seed. + +## What it does + +`start(request)` → +1. computes child depth = `depthOf(parent) + 1`; if `request.maxDepth` is set and exceeded, throws `SubagentDepthError` (the `depthLimit` capability); +2. creates a child via `ctx.agents.create` with a fresh `AgentId`/`SessionId`, the parent's `cwd` + `parentSession` lineage, 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 system prompt is NOT inherited); +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); +4. reads the result: the last `assistant/message` content (deep-cloned — the log is frozen) and the last `turn/end.reason` mapped to a `SubagentStopReason`. + +`dispose()` delegates to `AgentHandle.dispose()` (stop loop → await quiescence → remove session); `cancel()` cancels the child's in-flight turn. + +## Capabilities + +`{ outputSchema: false, depthLimit: true, toolFilter: false }`. It constructs the child, so it enforces a recursion cap; structured output and tool-scoping are deferred (the service rejects a request needing either before `start` runs). + +## Config + +| Key | Meaning | +|---|---| +| `providerName` | Registry name on `ctx.subagents` (default `spawn`). | + +## Depth tracking + +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`. Read it with the exported `depthOf(agent)`. diff --git a/packages/subagent/subagent-spawn/package.json b/packages/subagent/subagent-spawn/package.json new file mode 100644 index 0000000000..184296f01a --- /dev/null +++ b/packages/subagent/subagent-spawn/package.json @@ -0,0 +1,48 @@ +{ + "name": "@deepseek-ai/dsh-subagent-spawn", + "description": "In-process spawn subagent backend: runs a fresh child agent on ctx.agents", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-subagent": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tool-bash": "workspace:^", + "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@cordisjs/plugin-loader": "^1.0.0-rc.4", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/subagent/subagent-spawn/src/in-process.ts b/packages/subagent/subagent-spawn/src/in-process.ts new file mode 100644 index 0000000000..4b40bb667d --- /dev/null +++ b/packages/subagent/subagent-spawn/src/in-process.ts @@ -0,0 +1,164 @@ +/** + * The shared in-process subagent run driver. A subagent backend that runs the + * 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. Both in-process backends use this: + * `@deepseek-ai/dsh-subagent-spawn` (a fresh child) and + * `@deepseek-ai/dsh-subagent-fork` (a child seeded with a prefix of the + * parent's log) differ ONLY in the `seed` they pass — everything downstream + * (drive the child, read its final output, map the stop reason, dispose) is + * identical and lives here. + * + * @module @deepseek-ai/dsh-subagent-spawn/in-process + */ + +import { randomUUID } from 'node:crypto' +import type { Context } from 'cordis' +import { AgentId, type Agent, type AgentHandle, type AgentOptions } from '@deepseek-ai/dsh-agent' +import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' + +declare module '@deepseek-ai/dsh-agent' { + interface AgentOptions { + /** + * The agent's delegation depth in the subagent tree — 0 for a top-level + * (config/ACP-created) agent, parent depth + 1 for a subagent. Set by the + * in-process backends on every child they create so a nested spawn reads its + * parent's depth from `parent.options.subagentDepth` and the `depthLimit` + * capability can cap the tree. Merge-extensible field (the seam owns it; the + * loop neither sets nor reads it). + */ + subagentDepth?: number + } +} + +/** Read an agent's delegation depth (absent ⇒ a top-level agent, depth 0). */ +export function depthOf(agent: Agent): number { + return agent.options.subagentDepth ?? 0 +} + +/** Thrown when a spawn would exceed the request's `maxDepth` cap. */ +export class SubagentDepthError extends Error { + constructor(public readonly attemptedDepth: number, public readonly maxDepth: number) { + super(`subagent depth ${attemptedDepth} exceeds maxDepth ${maxDepth}`) + this.name = 'SubagentDepthError' + } +} + +/** Map a session `turn/end` reason to a {@link SubagentStopReason}. */ +function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason { + switch (reason?.kind) { + case 'completed': + return 'completed' + case 'max-tokens': + 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. + case 'error': + case 'disposed': + case 'interrupted': + default: + return 'error' + } +} + +/** Extra inputs the spawn/fork backends supply to {@link startInProcessRun}. */ +export interface InProcessRunOptions { + /** The provider name (`spawn`/`fork`), for error context only. */ + readonly providerName: string + /** + * The child session's seed: a balanced, contiguous-from-0 prefix of the + * parent's log (FORK), or `undefined` for a fresh child (SPAWN). + */ + readonly seed?: SessionEvent[] +} + +/** + * 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. + */ +export function startInProcessRun( + ctx: Context, + request: SubagentStartRequest, + options: InProcessRunOptions, +): SubagentRun { + const childDepth = depthOf(request.parent) + 1 + if (request.maxDepth !== undefined && childDepth > request.maxDepth) { + throw new SubagentDepthError(childDepth, request.maxDepth) + } + + const childId = AgentId(randomUUID()) + const parentHeader = request.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 parent's + // systemPrompt is NOT inherited — a fresh child is a clean specialist unless + // the caller supplies one. + const agentOptions: AgentOptions = { + ...request.parent.options.model !== undefined ? { model: request.parent.options.model } : {}, + ...request.agentOptions, + subagentDepth: childDepth, + } + + const handle: AgentHandle = ctx.agents.create({ + agentId: childId, + sessionId: SessionId(randomUUID()), + meta: { + ...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {}, + parentSession: parentHeader.id, + }, + ...options.seed !== undefined ? { seed: options.seed } : {}, + agentOptions, + }) + const child = handle.agent + + // 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). + const onAbort = (): void => { child.cancel('subagent cancelled') } + request.signal?.addEventListener('abort', onAbort, { once: true }) + + const result: Promise = (async () => { + try { + child.send(request.prompt) + await child.whenIdle() + return readResult(child) + } finally { + request.signal?.removeEventListener('abort', onAbort) + } + })() + + return { + id: childId, + result, + cancel(reason?: string): void { + child.cancel(reason ?? 'subagent cancelled') + }, + async dispose(): Promise { + request.signal?.removeEventListener('abort', onAbort) + await handle.dispose() + }, + } +} + +/** + * Read a settled child's terminal result from its session log: the last + * `assistant/message` content (deep-cloned — the log is frozen) and the last + * `turn/end` reason mapped to a {@link SubagentStopReason}. + */ +function readResult(child: Agent): SubagentResult { + const events = child.session.events + const lastMessage = events.findLast((e): e is SessionEvent<'assistant/message'> => e.type === 'assistant/message') + const lastEnd = events.findLast((e): e is SessionEvent<'turn/end'> => e.type === 'turn/end') + const output: ContentBlock[] = lastMessage ? structuredClone(lastMessage.data.content) : [] + return { output, stopReason: toStopReason(lastEnd?.data.reason) } +} diff --git a/packages/subagent/subagent-spawn/src/index.ts b/packages/subagent/subagent-spawn/src/index.ts new file mode 100644 index 0000000000..bbfcc03719 --- /dev/null +++ b/packages/subagent/subagent-spawn/src/index.ts @@ -0,0 +1,57 @@ +/** + * 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 fork sibling (`@deepseek-ai/dsh-subagent-fork`) shares this package's run + * driver ({@link startInProcessRun}) and differs ONLY in seeding the child with + * a prefix of the parent's log. + * + * Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default. + * + * @module @deepseek-ai/dsh-subagent-spawn + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import { startInProcessRun } from './in-process.ts' + +export { startInProcessRun, depthOf, SubagentDepthError } from './in-process.ts' +export type { InProcessRunOptions } from './in-process.ts' + +export const name = 'subagent-spawn' +export const inject = ['subagents', 'agents'] + +/** Config: the registry name to register the provider under. */ +export interface Config { + /** Provider name on `ctx.subagents` (default `spawn`). */ + providerName: string +} + +export const Config: z = z.object({ + providerName: z.string().default('spawn'), +}) + +/** + * The spawn provider. Supports `depthLimit` (it constructs the child, so it can + * enforce a recursion cap) but NOT `outputSchema` or `toolFilter` in this cut — + * a request that needs either is rejected by the service before `start` runs. + */ +class SpawnProvider implements SubagentProvider { + readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: true, toolFilter: false } + + constructor(readonly name: string, private readonly ctx: Context) {} + + start(request: SubagentStartRequest) { + // Fresh child: no seed. The shared driver mints ids, stamps cwd/lineage/ + // depth, drives the one-shot, and maps the result. + return startInProcessRun(this.ctx, request, { providerName: this.name }) + } +} + +export function apply(ctx: Context, config: Config): void { + ctx.subagents.registerProvider(new SpawnProvider(config.providerName, ctx)) +} diff --git a/packages/subagent/subagent-spawn/tests/harness.ts b/packages/subagent/subagent-spawn/tests/harness.ts new file mode 100644 index 0000000000..ff551cfc3f --- /dev/null +++ b/packages/subagent/subagent-spawn/tests/harness.ts @@ -0,0 +1,49 @@ +import { Context } from 'cordis' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import * as ToolBash from '@deepseek-ai/dsh-tool-bash' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import SubagentService from '@deepseek-ai/dsh-subagent' +import * as Spawn from '../src/index.ts' +import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent' + +/** + * Shared harness for the spawn-backend e2e: the full real stack (DeepSeek + * adapter + real bash tool + the subagent tool bound to the spawn backend), so + * a real parent agent can delegate to a real in-process child that does real + * work (writes a file). Lives outside the *.e2e.ts pattern so importing it never + * re-registers another file's tests. + */ +export async function spawnHarness(workdir: string): Promise { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] }) + await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) + await ctx.plugin(ToolBash) + await ctx.plugin(SubagentService) + await ctx.plugin(Spawn, { providerName: 'spawn' }) + // The model-facing subagent tool, bound to the spawn backend. + await ctx.plugin(ToolSubagent, { provider: 'spawn' }) + return ctx +} + +export function waitForIdle(ctx: Context, agent: Agent): Promise { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject === agent && status === 'idle') { + dispose() + resolve() + } + }) + }) +} diff --git a/packages/subagent/subagent-spawn/tests/spawn.e2e.ts b/packages/subagent/subagent-spawn/tests/spawn.e2e.ts new file mode 100644 index 0000000000..8179027976 --- /dev/null +++ b/packages/subagent/subagent-spawn/tests/spawn.e2e.ts @@ -0,0 +1,54 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { Context } from 'cordis' +import { AgentId } from '@deepseek-ai/dsh-agent' +import { spawnHarness, waitForIdle } from './harness.ts' + +/** + * With-key smoke for the in-process spawn backend: a REAL parent agent delegates + * to a REAL child (via the `subagent` tool → spawn backend) that uses the REAL + * bash tool to write a file, and we verify the WORLD (the file on disk) — not + * the agent's self-report. This is the "green units, broken product" guard: + * mocks prove the plumbing, only a real model proves a parent can actually drive + * a child to do real work. Key-gated (self-skips without DEEPSEEK_API_KEY). + */ + +let ctx: Context | undefined +let workdir: string | undefined + +afterEach(async () => { + await ctx?.fiber.dispose() + ctx = undefined + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + workdir = undefined +}) + +describe.skipIf(!process.env.DEEPSEEK_API_KEY)('spawn backend with-key smoke', () => { + it('a parent delegates to a child that writes a file on disk', async () => { + workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-spawn-e2e-')) + ctx = await spawnHarness(workdir) + const parent = ctx.agentLoop.create(AgentId('e2e-parent'), { + model: 'deepseek-v4-flash', + systemPrompt: 'You are an orchestrator. To do file work, delegate to a subagent with the `subagent` tool — ' + + 'give it a complete, standalone instruction. Report only when done.', + }) + + parent.send([{ type: 'text', text: + 'Use the subagent tool to delegate this exact task: "Use the bash tool to write the text ' + + 'SUBAGENT_WAS_HERE into a file named proof.txt in the current directory." ' + + 'After the subagent finishes, tell me it is done.' }]) + await waitForIdle(ctx, parent) + + // Verify the WORLD: the child actually wrote the file. + const proof = await readFile(join(workdir, 'proof.txt'), 'utf8') + expect(proof).toContain('SUBAGENT_WAS_HERE') + + // The parent's log records the subagent tool/call + its result (not the + // child's internal steps). + const events = [...parent.session.events] + const subagentCalls = events.filter(e => e.type === 'tool/call' && e.data.name === 'subagent') + expect(subagentCalls.length).toBeGreaterThan(0) + }, 180_000) +}) diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts new file mode 100644 index 0000000000..830aa81ec8 --- /dev/null +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import * as Invariants from '@deepseek-ai/dsh-invariants' +import SubagentService from '@deepseek-ai/dsh-subagent' +import { MockAdapter, maxTokensResponse, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import * as spawn from '../src/index.ts' +import { depthOf, SubagentDepthError } from '../src/in-process.ts' + +type Script = ConstructorParameters[0] + +/** + * Drives the REAL spawn backend end-to-end: a real agent loop + a scripted mock + * MODEL (the only mocked boundary) + the real SubagentService + the real + * dsh-invariants plugin (so a malformed child session log would fail the test). + * The parent is a real config agent; the spawn provider creates a real child + * agent on the same context and we assert its output. + */ +async function setup(script: Script) { + const ctx = new Context() + const adapter = new MockAdapter(script) + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(Invariants) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + await ctx.plugin(spawn, { providerName: 'spawn' }) + ctx.llm.registerAdapter(['mock'], adapter) + const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) + return { ctx, parent, adapter } +} + +function text(blocks: { type: string; text?: string }[]): string { + return blocks.filter(b => b.type === 'text').map(b => b.text).join('') +} + +describe('dsh-subagent-spawn', () => { + it('runs a fresh child to completion and returns its final assistant output', async () => { + // One model call for the child: a plain text answer. + const { ctx, parent } = await setup([textResponse('child answer')]) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'do X' }], parent }) + const result = await run.result + expect(result.stopReason).toBe('completed') + expect(text(result.output)).toBe('child answer') + await run.dispose() + }) + + it('gives the child its OWN session (not the parent\'s), with parentSession lineage', async () => { + const { ctx, parent } = await setup([textResponse('hi')]) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + await run.result + const child = ctx.agents.get(run.id)! + expect(child.session.header.id).not.toBe(parent.session.header.id) + expect(child.session.header.parentSession).toBe(parent.session.header.id) + await run.dispose() + }) + + it('a fresh child does NOT inherit the parent conversation (its log starts empty before the prompt)', async () => { + // Drive the parent through one real turn so it has history, THEN spawn. + const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('child sees nothing')]) + parent.send([{ type: 'text', text: 'parent prompt' }]) + await parent.whenIdle() + const parentEventCount = parent.session.events.length + expect(parentEventCount).toBeGreaterThan(0) + + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'child prompt' }], parent }) + await run.result + const child = ctx.agents.get(run.id)! + // The child's first user/message is its OWN prompt, not the parent's history. + const firstUser = child.session.events.find(e => e.type === 'user/message') + expect(firstUser).toBeDefined() + await run.dispose() + }) + + it('disposes the child to quiescence (agent removed from the registry)', async () => { + const { ctx, parent } = await setup([textResponse('x')]) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + await run.result + expect(ctx.agents.get(run.id)).toBeDefined() + await run.dispose() + // After dispose, the child is unregistered (the AgentHandle teardown ran). + expect(ctx.agents.get(run.id)).toBeUndefined() + }) + + it('stamps child depth = parent depth + 1 (via the merged AgentOptions field)', async () => { + const { ctx, parent } = await setup([textResponse('x')]) + expect(depthOf(parent)).toBe(0) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + await run.result + const child = ctx.agents.get(run.id)! + expect(depthOf(child)).toBe(1) + await run.dispose() + }) + + it('refuses to spawn past maxDepth (depthLimit capability)', async () => { + const { ctx, parent } = await setup([]) + // parent is depth 0, child would be depth 1 — cap at 0 forbids any child. + expect(() => ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 })) + .toThrow(SubagentDepthError) + }) + + it('maps a child that hit its token ceiling to stopReason "max-tokens"', async () => { + const { ctx, parent } = await setup([maxTokensResponse('cut off')]) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + const result = await run.result + expect(result.stopReason).toBe('max-tokens') + await run.dispose() + }) + + it('maps a child whose turn errored (script exhausted) to stopReason "error" with empty output', async () => { + // Empty script: the child's first model call throws "script exhausted", the + // turn ends `error`, and there is no assistant/message → empty output. + const { ctx, parent } = await setup([]) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + const result = await run.result + expect(result.stopReason).toBe('error') + expect(result.output).toEqual([]) + await run.dispose() + }) + + it('cancelling a running child settles the run as aborted (the abort bridge + cancel())', async () => { + // 'hang' makes the child's model stream one chunk then wait until aborted. + const controller = new AbortController() + const { ctx, parent } = await setup(['hang']) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal }) + // Let the child's turn start, then abort via the request signal (the + // backend bridges it to child.cancel()). + await new Promise(r => setTimeout(r, 30)) + controller.abort() + const result = await run.result + expect(result.stopReason).toBe('aborted') + await run.dispose() + }) + + it('run.cancel() also cancels the child directly', async () => { + const { ctx, parent } = await setup(['hang']) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + await new Promise(r => setTimeout(r, 30)) + run.cancel('test cancel') + const result = await run.result + expect(result.stopReason).toBe('aborted') + await run.dispose() + }) + + it('run.cancel() with no reason uses the default cancel reason', async () => { + const { ctx, parent } = await setup(['hang']) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + await new Promise(r => setTimeout(r, 30)) + run.cancel() + const result = await run.result + expect(result.stopReason).toBe('aborted') + await run.dispose() + }) + + it('does not expose the optional runtime methods (sendMessage/resume) in this cut', async () => { + const { ctx, parent } = await setup([textResponse('x')]) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + expect('sendMessage' in run).toBe(false) + expect('resume' in run).toBe(false) + await run.result + await run.dispose() + }) + + it('inherits the parent cwd into the child session', async () => { + const { ctx } = await setup([textResponse('x')]) + // A parent WITH a cwd (config agents have none, so create one explicitly). + const parentHandle = ctx.agents.create({ + agentId: AgentId('cwd-parent'), + sessionId: SessionId('cwd-parent-session'), + meta: { cwd: '/tmp/parent-workspace' }, + agentOptions: { model: 'mock' }, + }) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent: parentHandle.agent }) + await run.result + const child = ctx.agents.get(run.id)! + expect(child.session.header.cwd).toBe('/tmp/parent-workspace') + await run.dispose() + await parentHandle.dispose() + }) + + it('uses request.agentOptions.model when the parent has no model of its own', async () => { + const { ctx } = await setup([textResponse('explicit model child')]) + // A parent with NO model (its own turns would need one supplied per-request). + const parentHandle = ctx.agents.create({ + agentId: AgentId('modelless-parent'), + sessionId: SessionId('modelless-parent-session'), + agentOptions: {}, + }) + // The request supplies the child's model explicitly. + const run = ctx.subagents.start('spawn', { + prompt: [{ type: 'text', text: 'p' }], + parent: parentHandle.agent, + agentOptions: { model: 'mock' }, + }) + const result = await run.result + expect(result.stopReason).toBe('completed') + expect(text(result.output)).toBe('explicit model child') + await run.dispose() + await parentHandle.dispose() + }) + + it('advertises depthLimit but not outputSchema/toolFilter', async () => { + const { ctx } = await setup([]) + const provider = ctx.subagents.getProvider('spawn')! + expect(provider.capabilities).toEqual({ outputSchema: false, depthLimit: true, toolFilter: false }) + }) + + it('unregisters the provider when its fiber is disposed (HMR safety)', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(AgentRegistry) + const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) + expect(ctx.subagents.list()).toEqual(['spawn']) + await fiber.dispose() + expect(ctx.subagents.list()).toEqual([]) + }) + + it('has the namespace-plugin export shape (no stray default)', () => { + expect('default' in spawn).toBe(false) + expect(spawn.name).toBe('subagent-spawn') + expect(spawn.inject).toEqual(['subagents', 'agents']) + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(spawn) as Record + expect(unwrapped).toBe(spawn) + expect(unwrapped.name).toBe('subagent-spawn') + expect(unwrapped.inject).toEqual(['subagents', 'agents']) + expect(typeof unwrapped.apply).toBe('function') + }) +}) diff --git a/packages/subagent/subagent-spawn/tsconfig.json b/packages/subagent/subagent-spawn/tsconfig.json new file mode 100644 index 0000000000..5e6c9f9100 --- /dev/null +++ b/packages/subagent/subagent-spawn/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../subagent" + } + ] +} diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index 67fcf09697..fe24213768 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -211,6 +211,16 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined) * the snapshot harness runs one ACP session per scenario to guarantee that. The * cursor is advanced synchronously at listener-invocation time (not lazily * inside the generator) so call ORDER, not iteration order, fixes the mapping. + * + * TODO(subagent-snapshots): this single global cursor cannot route calls to the + * right agent when a parent and an in-process subagent both stream on one ctx. + * Snapshot coverage of nested agents needs either per-session-keyed replay (a + * `Map` fed by the calling agent on the `agent/request` + * waterfall, which carries the agent) or a call-ordered merge of the parent and + * child session logs (sound because subagent execution is strictly nested — + * the parent blocks on the child). Tracked as a stacked follow-up to the + * in-process subagent backends; see the subagent RFC's "Snapshot coverage of + * nested agents" deferral. */ export function installLlmReplay(ctx: Context, config: ReplayConfig): () => void { const entries = loadReplayScript(config) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d56ed4e19..b262257b14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -333,6 +333,95 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/subagent/subagent-fork: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-loader': + specifier: ^1.0.0-rc.4 + version: 1.0.0-rc.4(cordis@4.0.0-rc.6) + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../subagent + '@deepseek-ai/dsh-subagent-spawn': + specifier: workspace:^ + version: link:../subagent-spawn + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + + packages/subagent/subagent-spawn: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-loader': + specifier: ^1.0.0-rc.4 + version: 1.0.0-rc.4(cordis@4.0.0-rc.6) + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-bash-local': + specifier: workspace:^ + version: link:../../bash/bash-local + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-llm-deepseek': + specifier: workspace:^ + version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../subagent + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-bash': + specifier: workspace:^ + version: link:../../bash/tool-bash + '@deepseek-ai/dsh-tool-subagent': + specifier: workspace:^ + version: link:../tool-subagent + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/subagent/tool-subagent: dependencies: schemastery: diff --git a/tsconfig.build.json b/tsconfig.build.json index 44f984a784..4f0528961d 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -34,6 +34,8 @@ { "path": "./packages/support/llm-replay" }, { "path": "./packages/subagent/subagent" }, { "path": "./packages/support/subagent-mock" }, - { "path": "./packages/subagent/tool-subagent" } + { "path": "./packages/subagent/tool-subagent" }, + { "path": "./packages/subagent/subagent-spawn" }, + { "path": "./packages/subagent/subagent-fork" } ] } From b82c310db391df7ac60e1889983d5ccc24e747e2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 22 Jun 2026 06:47:20 +0800 Subject: [PATCH 2/5] Fix subagent in-process result scoping (Codex review round 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two merge-blocking bugs in the shared in-process run driver, both rooted in `readResult` scanning the whole child session and deriving the stop reason only from `turn/end`: - A pre-turn `cancel()` cleared the queued prompt before any `turn/end` was logged, so the run settled `error` instead of `aborted`, violating the `SubagentRun.cancel()` contract. The driver now tracks that a cancel was requested and maps the no-turn case to `aborted`. - A fork child whose own turn produced no `assistant/message` returned the SEEDED parent's last message as a `completed` success. `readResult` now scopes to the child's OWN events (after the seed prefix), so a message-less child yields empty output. Both fixes carry a regression test proven to go red on the pre-fix driver. Also: correct the `SubagentRun.id` / event-payload docs (it is the child AGENT id, not a session id — the backend mints distinct tokens); refresh the stale `coding-agent` welcome string (subagent is now a tool); and replace the stale `TODO(sub-agents)` "deferred" prose in the Agent interface, core.md, and architecture.md with an accurate pointer to the realized seam. --- docs/architecture.md | 2 +- docs/cordis-catalog/events-and-services.md | 28 ++++++------- docs/core-data-structures/core.md | 11 ++--- examples/coding-agent/cordis.yml | 2 +- packages/core/agent-loop/src/index.ts | 4 -- packages/core/agent/src/types.ts | 11 ++--- .../subagent-fork/tests/subagent-fork.spec.ts | 25 +++++++++++ .../subagent/subagent-spawn/src/in-process.ts | 42 ++++++++++++++----- .../tests/subagent-spawn.spec.ts | 16 +++++++ packages/subagent/subagent/src/index.ts | 4 +- packages/subagent/subagent/src/types.ts | 2 +- 11 files changed, 104 insertions(+), 43 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 2b05e613b1..612e73ddce 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -116,7 +116,7 @@ Tool schemas are deliberately **part of the assembly**: "what the model is told - `whenIdle()` — resolves once the agent reaches quiescence after settling out of `running` (resolves immediately when already idle; awaits the loop exit when disposed). A non-owner's quiescence-observation hook: it lets a consumer await the current work settling **without** disposing the agent. It is NOT teardown — it does not stop queued work, unregister the agent, or detach the session; a lifecycle owner tears an agent down with `await AgentHandle.dispose()` (which stops the loop, awaits its exit, and unregisters). - `session`, `status`, `options` -**TODO(sub-agents)**: `spawn`/`fork` land on `AgentLoop.create()` — fork seeds the child Session with the parent's event log, spawn starts fresh; children are ordinary `Agent` handles so `steer()` and event subscription work uniformly. Inter-agent channels beyond these primitives are deliberately deferred. +**Subagents**: `spawn`/`fork` are realized by the [`@deepseek-ai/dsh-subagent`](../packages/subagent/subagent) seam (a named-provider registry on `ctx.subagents`), not a method on `Agent`. The in-process backends create the child via `ctx.agents.create` — fork seeds the child Session with a balanced completed-turn prefix of the parent's log (`CreateAgentOptions.seed`), spawn starts fresh; children are ordinary `Agent` handles so `steer()` and event subscription work uniformly. Out-of-process transports (ACP, and later A2A / Codex app-server / Claude Code SDK) register as sibling providers. See [docs/core-data-structures/subagent.md](core-data-structures/subagent.md) and [the subagent RFC](rfc/proposed/feature/2026-06-21-subagent-capability-seam.md). Inter-agent channels beyond delegation remain deferred. ### Loop lifecycle (session / turn / step) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 36edfae505..e0fe1cd6c2 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -25,7 +25,7 @@ An agent was registered in the AgentRegistry and is ready to receive messages. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:136`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:137`](../../packages/core/agent/src/types.ts) #### `agent/disposed` — emit @@ -37,7 +37,7 @@ An agent was disposed and removed from the registry; its fiber and any in-flight Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:142`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:143`](../../packages/core/agent/src/types.ts) #### `agent/error` — emit @@ -49,7 +49,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:219`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:220`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -61,7 +61,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:155`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:156`](../../packages/core/agent/src/types.ts) #### `agent/request` — waterfall @@ -73,7 +73,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:188`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:189`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -85,7 +85,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:149`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:150`](../../packages/core/agent/src/types.ts) #### `agent/steering` — emit @@ -97,7 +97,7 @@ Steering content was injected into a running turn. Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:213`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/agent/src/types.ts) #### `agent/step-end` — emit @@ -109,7 +109,7 @@ A step ended. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:179`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:180`](../../packages/core/agent/src/types.ts) #### `agent/step-result` — waterfall @@ -121,7 +121,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:194`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:195`](../../packages/core/agent/src/types.ts) #### `agent/step-start` — emit @@ -133,7 +133,7 @@ A step (one model call plus its tool dispatch) began. `step` is 1-based within t Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:174`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:175`](../../packages/core/agent/src/types.ts) #### `agent/stream-chunk` — emit @@ -145,7 +145,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed). Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/core/agent/src/types.ts:208`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:209`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -157,7 +157,7 @@ Waterfall: override the turn-continuation decision. The default (computed by the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:201`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:202`](../../packages/core/agent/src/types.ts) #### `agent/turn-end` — emit @@ -169,7 +169,7 @@ A turn ended. `reason` distinguishes a clean stop from a truncated or aborted on Types: [Agent](../core-data-structures/core.md) · [TurnEndReason](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:168`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:169`](../../packages/core/agent/src/types.ts) #### `agent/turn-start` — emit @@ -181,7 +181,7 @@ A turn began. `turn` is the 1-based turn number within the session. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:162`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/types.ts) ### `llm/*` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 117d1e3606..49c1559c40 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -273,11 +273,12 @@ interface Agent { */ whenIdle(): Promise - // TODO(sub-agents): spawn/fork seams — semantics deliberately deferred. - // The intended shape: a creation option referencing a parent agent - // (fork = seed the child Session with the parent's event log; spawn = - // fresh Session), with the child returned as an Agent handle so steer() - // and event subscription work uniformly. See docs/architecture.md. + // Subagent delegation is realized on top of this interface by the + // `@deepseek-ai/dsh-subagent` seam, not by a method here: a backend creates + // the child through `ctx.agents.create` (fork seeds the child Session with a + // balanced prefix of the parent's log via `CreateAgentOptions.seed`; spawn + // starts fresh) and drives it as an ordinary Agent handle, so steer() and + // event subscription work uniformly. See docs/core-data-structures/subagent.md. } ``` diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index c8371d3cba..136031062a 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -44,7 +44,7 @@ # under ./.sessions); unset starts a fresh session each run. resumeSessionId: !!js process.env.RESUME_SESSION_ID persistenceRoot: './.sessions' - welcome: 'coding-agent ready. Give it a coding task (bash is its only tool).' + welcome: 'coding-agent ready. Give it a coding task (its tools are bash and subagent).' systemPrompt: | You are coding-agent, a CLI coding assistant. diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 3179674366..e5393ed0aa 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -121,10 +121,6 @@ export class AgentLoop extends Service implements AgentFactory { * deliberate resume-or-create policy (resume the prior session if one exists, * else start fresh) or an explicit caller-chosen session id — revisit when the * UI/ACP path owns session selection. - * - * TODO(sub-agents): spawn/fork land here — accept a parent agent reference; - * fork seeds the new Session with the parent's event log, spawn starts - * fresh; the child is returned as a regular Agent handle. */ create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent { this.assertAgentIdFree(id) diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 6d27bf7256..efe392155c 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -118,11 +118,12 @@ export interface Agent { */ whenIdle(): Promise - // TODO(sub-agents): spawn/fork seams — semantics deliberately deferred. - // The intended shape: a creation option referencing a parent agent - // (fork = seed the child Session with the parent's event log; spawn = - // fresh Session), with the child returned as an Agent handle so steer() - // and event subscription work uniformly. See docs/architecture.md. + // Subagent delegation is realized on top of this interface by the + // `@deepseek-ai/dsh-subagent` seam, not by a method here: a backend creates + // the child through `ctx.agents.create` (fork seeds the child Session with a + // balanced prefix of the parent's log via `CreateAgentOptions.seed`; spawn + // starts fresh) and drives it as an ordinary Agent handle, so steer() and + // event subscription work uniformly. See docs/core-data-structures/subagent.md. } declare module 'cordis' { diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index d550101cb9..96cdd55141 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -10,11 +10,16 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService from '@deepseek-ai/dsh-subagent' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import type { StreamChunk } from '@deepseek-ai/dsh-llm' import * as fork from '../src/index.ts' import { completedTurnPrefix } from '../src/index.ts' type Script = ConstructorParameters[0] +/** A bare `stop` finish that streams no content → the turn ends `completed` + * with NO `assistant/message` of its own. */ +const emptyStop: StreamChunk[] = [{ type: 'finish', reason: { kind: 'stop' } }] + /** * Drives the REAL fork backend with a real loop + scripted mock MODEL + the * real dsh-invariants plugin. The invariants plugin re-replays a seeded child @@ -132,6 +137,26 @@ describe('dsh-subagent-fork', () => { await run.dispose() }) + 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. + const { ctx, parent } = await setup([textResponse('parent stale'), emptyStop]) + parent.send([{ type: 'text', text: 'parent question' }]) + await parent.whenIdle() + + const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child question' }], parent }) + const result = await run.result + // The child completed its own (empty) turn — completed, but with NO output + // borrowed from the seeded parent prefix. + expect(result.stopReason).toBe('completed') + expect(result.output).toEqual([]) + await run.dispose() + }) + it('advertises depthLimit but not outputSchema/toolFilter', async () => { const { ctx } = await setup([]) expect(ctx.subagents.getProvider('fork')!.capabilities).toEqual({ outputSchema: false, depthLimit: true, toolFilter: false }) diff --git a/packages/subagent/subagent-spawn/src/in-process.ts b/packages/subagent/subagent-spawn/src/in-process.ts index 4b40bb667d..3b04b55c86 100644 --- a/packages/subagent/subagent-spawn/src/in-process.ts +++ b/packages/subagent/subagent-spawn/src/in-process.ts @@ -99,6 +99,11 @@ export function startInProcessRun( } const childId = AgentId(randomUUID()) + // The child's OWN events begin after the seed (fork seeds the parent's + // completed-turn prefix; spawn seeds nothing). `readResult` scopes to this + // boundary so a child that produces no message of its own never returns the + // SEEDED parent's last assistant message as its result. + const seedLength = options.seed?.length ?? 0 const parentHeader = request.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 parent's @@ -124,14 +129,23 @@ 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). - const onAbort = (): void => { child.cancel('subagent cancelled') } + // `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. + let cancelled = false + const requestCancel = (reason: string): void => { + cancelled = true + child.cancel(reason) + } + const onAbort = (): void => { requestCancel('subagent cancelled') } request.signal?.addEventListener('abort', onAbort, { once: true }) const result: Promise = (async () => { try { child.send(request.prompt) await child.whenIdle() - return readResult(child) + return readResult(child, seedLength, cancelled) } finally { request.signal?.removeEventListener('abort', onAbort) } @@ -141,7 +155,7 @@ export function startInProcessRun( id: childId, result, cancel(reason?: string): void { - child.cancel(reason ?? 'subagent cancelled') + requestCancel(reason ?? 'subagent cancelled') }, async dispose(): Promise { request.signal?.removeEventListener('abort', onAbort) @@ -151,14 +165,22 @@ export function startInProcessRun( } /** - * Read a settled child's terminal result from its session log: the last - * `assistant/message` content (deep-cloned — the log is frozen) and the last - * `turn/end` reason mapped to a {@link SubagentStopReason}. + * 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`. */ -function readResult(child: Agent): SubagentResult { - const events = child.session.events - const lastMessage = events.findLast((e): e is SessionEvent<'assistant/message'> => e.type === 'assistant/message') - const lastEnd = events.findLast((e): e is SessionEvent<'turn/end'> => e.type === 'turn/end') +function readResult(child: Agent, seedLength: number, cancelled: boolean): SubagentResult { + const own = child.session.events.slice(seedLength) + const lastMessage = own.findLast((e): e is SessionEvent<'assistant/message'> => e.type === 'assistant/message') + const lastEnd = own.findLast((e): e is SessionEvent<'turn/end'> => e.type === 'turn/end') const output: ContentBlock[] = lastMessage ? structuredClone(lastMessage.data.content) : [] + if (lastEnd === undefined && cancelled) return { output, stopReason: 'aborted' } return { output, stopReason: toStopReason(lastEnd?.data.reason) } } diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 830aa81ec8..4cbf1ca9d3 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -128,6 +128,22 @@ describe('dsh-subagent-spawn', () => { await run.dispose() }) + 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. + const { ctx, parent } = await setup([]) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + run.cancel('early') + const result = await run.result + expect(result.stopReason).toBe('aborted') + expect(result.output).toEqual([]) + await run.dispose() + }) + it('cancelling a running child settles the run as aborted (the abort bridge + cancel())', async () => { // 'hang' makes the child's model stream one chunk then wait until aborted. const controller = new AbortController() diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index c7d954f09c..356ad60a00 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -70,7 +70,7 @@ declare module 'cordis' { export interface SubagentRunInfo { /** The provider that started the run. */ provider: string - /** The child agent/session id. */ + /** The child agent's id. */ id: AgentId } @@ -78,7 +78,7 @@ export interface SubagentRunInfo { export interface SubagentRunEndInfo { /** The provider that ran it. */ provider: string - /** The child agent/session id. */ + /** The child agent's id. */ id: AgentId /** The terminal stop reason. */ stopReason: SubagentResult['stopReason'] diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 0e04acb317..fb60d5667c 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -122,7 +122,7 @@ export interface SubagentResult { * presence of the method IS the capability — narrow before calling. */ export interface SubagentRun { - /** The child agent's id (also its session id token, for correlation). */ + /** The child agent's id (use `ctx.agents.get(id)` to reach the live child). */ readonly id: AgentId /** * Resolves with the child's terminal {@link SubagentResult} when the run From 9c1048f2b599ce3e3e8fa127a45237715e61a138 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 22 Jun 2026 07:32:09 +0800 Subject: [PATCH 3/5] Honor an already-aborted request signal in the subagent driver (Codex review round 2) A request signal aborted BEFORE the run starts never fires an `abort` event (`addEventListener` only fires on the transition), so the backend-level bridge missed it and ran the child to `completed`. The driver now checks `request.signal?.aborted` at the top of the result path and settles `aborted` without running the child. Regression test proven red on the pre-fix code. Also refresh two stale RFC prose blocks the round-1 fix left behind: the subagent RFC's Problem statement (cited the removed `TODO(sub-agents)` markers and claimed nothing existed yet) and the unify-id RFC's fork/spawn risk bullet (described the seam as "explicitly deferred" via `AgentLoop.create`'s old TODO), now pointing at the realized seam. --- .../2026-06-21-subagent-capability-seam.md | 2 +- .../2026-06-20-unify-agent-and-session-id.md | 2 +- .../subagent/subagent-spawn/src/in-process.ts | 5 +++++ .../subagent-spawn/tests/subagent-spawn.spec.ts | 16 ++++++++++++++++ 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md b/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md index 02c4fa36b4..d99637b550 100644 --- a/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md +++ b/docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md @@ -6,7 +6,7 @@ Status: proposed ## Problem -The harness has a long-deferred seam for **subagents** — an agent delegating work to another agent. The intent is sketched in two `TODO(sub-agents)` markers ([packages/core/agent/src/types.ts](../../../../packages/core/agent/src/types.ts), [packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)): a creation option referencing a parent agent (fork = seed the child session with the parent's event log; spawn = fresh session), with the child returned as an `Agent` handle so steering and event subscription work uniformly. No service, vocabulary, or implementation exists yet. +The harness has a long-deferred seam for **subagents** — an agent delegating work to another agent. The intent was sketched in the `Agent`/`AgentLoop` interfaces ([packages/core/agent/src/types.ts](../../../../packages/core/agent/src/types.ts), [packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)): a creation option referencing a parent agent (fork = seed the child session with the parent's event log; spawn = fresh session), with the child returned as an `Agent` handle so steering and event subscription work uniformly. This RFC realizes that seam (see the implementation-status banner above for what has landed); the design below is the proposal it was argued from, when no service, vocabulary, or implementation yet existed. The distinctive requirement — the one that shapes the whole design — is that **multiple subagent implementations must coexist at runtime**. A parent may want a cheap in-process child for a scoped subtask AND an isolated out-of-process child (over ACP) in the same session. The transports we foresee: diff --git a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md index 19bde62d01..2455981ba8 100644 --- a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md +++ b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md @@ -46,7 +46,7 @@ The genuine risks of collapsing the two ids into one (the case AGAINST this prop - **It forecloses a one-agent-resumes-many-sessions / one-session-driven-by-many-agents future.** Today the separate ids leave room for an agent (a stable actor) to detach from one session and attach to another, or for a handoff where a new agent process adopts an existing session under a new actor handle. Unifying makes "agent" and "session" the same lifetime, so any such future needs a NEW seam (e.g. an explicit `actorId` distinct from the session) — re-introducing the very separation we removed. We judge this generality currently unused, but it is a door this change closes. -- **Sub-agents / fork / spawn (an explicitly deferred seam) may WANT a stable actor id across forked sessions.** `AgentLoop.create`'s `TODO(sub-agents)` envisions a child agent seeded from a parent's event log. If the design wants "the same agent identity across a fork" (parent and child share an actor but have distinct session logs), a unified id blocks it. The implementing PR must check the intended fork/spawn model BEFORE unifying, or accept that fork always mints a fresh combined id. +- **Subagents / fork / spawn may WANT a stable actor id across forked sessions.** The [subagent seam](../feature/2026-06-21-subagent-capability-seam.md) runs a child agent seeded from a parent's event log (fork). If a future design wants "the same agent identity across a fork" (parent and child share an actor but have distinct session logs), a unified id blocks it. The implementing PR must check the intended fork/spawn model BEFORE unifying, or accept that fork always mints a fresh combined id. (As shipped, each subagent child mints its own distinct agent id — `parentSession` records lineage — so the seam does not currently rely on a shared actor id, but unifying would foreclose adding one.) - **The config-driven resume-or-create policy becomes load-bearing, not cosmetic.** Today the per-run-uuid session id quietly sidesteps the "a fixed id collides with its own on-disk log on the second run" problem. Once the id is unified and stable, a config agent restarting MUST decide resume-vs-fresh deliberately — there is no longer a throwaway session id to hide behind. Getting this wrong reintroduces the create-collision the uuid was avoiding (a durable backend refuses to re-create an id whose log exists). This is the one real design decision the implementing PR owns, and it is easy to get subtly wrong. diff --git a/packages/subagent/subagent-spawn/src/in-process.ts b/packages/subagent/subagent-spawn/src/in-process.ts index 3b04b55c86..e121611697 100644 --- a/packages/subagent/subagent-spawn/src/in-process.ts +++ b/packages/subagent/subagent-spawn/src/in-process.ts @@ -143,6 +143,11 @@ export function startInProcessRun( const result: Promise = (async () => { try { + // A signal already aborted BEFORE the run starts never fires an `abort` + // event (`addEventListener` only fires on the transition), so the listener + // above won't catch it — settle `aborted` without running the child rather + // than completing an already-cancelled request. + if (request.signal?.aborted) return { output: [], stopReason: 'aborted' } child.send(request.prompt) await child.whenIdle() return readResult(child, seedLength, cancelled) diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 4cbf1ca9d3..c57819d86a 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -128,6 +128,22 @@ describe('dsh-subagent-spawn', () => { await run.dispose() }) + 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. + const controller = new AbortController() + controller.abort() + const { ctx, parent } = await setup([]) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal }) + const result = await run.result + expect(result.stopReason).toBe('aborted') + expect(result.output).toEqual([]) + await run.dispose() + }) + 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 From 0c9ea3145f1afb25b67317713d770550b08073b4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:27:38 +0800 Subject: [PATCH 4/5] Extract the shared in-process driver into dsh-subagent-inprocess (review feedback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared run driver lived inside dsh-subagent-spawn, so the spawn package carried fork-aware seeding logic and dsh-subagent-fork depended backward on dsh-subagent-spawn — the two in-process backends were not independent. Move the driver (startInProcessRun, depthOf, SubagentDepthError, InProcessRunOptions) into a new pure-library package @deepseek-ai/dsh-subagent-inprocess that registers nothing. spawn and fork now both depend only on that driver and neither knows about the other; spawn no longer re-exports it and fork no longer imports from spawn. Also wire BOTH backends in examples/coding-agent/cordis.yml (config-only): load dsh-subagent-spawn + dsh-subagent-fork + two dsh-tool-subagent instances with distinct toolNames (subagent → spawn, subagent_fork → fork), demonstrating that exposing multiple transports needs no code change. --- docs/module-graph.md | 17 ++-- examples/coding-agent/cordis.yml | 28 ++++-- packages/subagent/README.md | 5 +- packages/subagent/subagent-fork/package.json | 3 +- packages/subagent/subagent-fork/src/index.ts | 8 +- packages/subagent/subagent-fork/tsconfig.json | 2 +- .../subagent/subagent-inprocess/README.md | 28 ++++++ .../subagent/subagent-inprocess/package.json | 40 +++++++++ .../src/index.ts} | 21 ++--- .../tests/subagent-inprocess.spec.ts | 85 +++++++++++++++++++ .../subagent/subagent-inprocess/tsconfig.json | 30 +++++++ packages/subagent/subagent-spawn/README.md | 14 +-- packages/subagent/subagent-spawn/package.json | 5 +- packages/subagent/subagent-spawn/src/index.ts | 11 +-- .../tests/subagent-spawn.spec.ts | 2 +- .../subagent/subagent-spawn/tsconfig.json | 12 +-- pnpm-lock.yaml | 36 ++++++++ tsconfig.build.json | 1 + 18 files changed, 286 insertions(+), 62 deletions(-) create mode 100644 packages/subagent/subagent-inprocess/README.md create mode 100644 packages/subagent/subagent-inprocess/package.json rename packages/subagent/{subagent-spawn/src/in-process.ts => subagent-inprocess/src/index.ts} (91%) create mode 100644 packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts create mode 100644 packages/subagent/subagent-inprocess/tsconfig.json diff --git a/docs/module-graph.md b/docs/module-graph.md index 0724130ced..a91c5bed98 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -60,13 +60,13 @@ graph TD agent-core --> system-prompt agent-core --> tool-bash agent-core --> tools + subagent-inprocess --> agent + subagent-inprocess --> llm + subagent-inprocess --> session + subagent-inprocess --> subagent subagent-mock --> agent subagent-mock --> llm subagent-mock --> subagent - subagent-spawn --> agent - subagent-spawn --> llm - subagent-spawn --> session - subagent-spawn --> subagent tool-subagent --> agent tool-subagent --> llm tool-subagent --> subagent @@ -82,7 +82,9 @@ graph TD subagent-fork --> agent subagent-fork --> session subagent-fork --> subagent - subagent-fork --> subagent-spawn + subagent-fork --> subagent-inprocess + subagent-spawn --> subagent + subagent-spawn --> subagent-inprocess ``` | Package | Depends on | @@ -108,9 +110,10 @@ graph TD | `subagent` | `agent`, `llm`, `tools` | | `tool-bash` | `agent`, `bash`, `llm`, `tools` | | `agent-core` | `agent`, `agent-loop`, `invariants`, `llm`, `session`, `system-prompt`, `tool-bash`, `tools` | +| `subagent-inprocess` | `agent`, `llm`, `session`, `subagent` | | `subagent-mock` | `agent`, `llm`, `subagent` | -| `subagent-spawn` | `agent`, `llm`, `session`, `subagent` | | `tool-subagent` | `agent`, `llm`, `subagent`, `tools` | | `acp-agent` | `acp`, `agent-core`, `session-persistence-jsonl` | | `stdio-agent` | `agent`, `agent-core`, `session`, `session-persistence-jsonl`, `ui-stdio` | -| `subagent-fork` | `agent`, `session`, `subagent`, `subagent-spawn` | +| `subagent-fork` | `agent`, `session`, `subagent`, `subagent-inprocess` | +| `subagent-spawn` | `subagent`, `subagent-inprocess` | diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index 136031062a..0347115cd5 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -57,17 +57,21 @@ Use the subagent tool to delegate a focused, self-contained subtask to a fresh child agent (it works in its own context and returns only - its final result) — give it a complete, standalone instruction. + its final result) — give it a complete, standalone instruction. Use + subagent_fork instead when the subtask needs THIS conversation's + context: the child inherits the log so far. Check the [exit code: N] marker on every command; investigate failures before moving on. Verify your work by running the code or tests. Keep answers brief and factual. -# The subagent seam + an in-process spawn backend + the model-facing `subagent` -# tool, as leaf entries after the app (which provides ctx.agents/ctx.tools). The -# tool is bound to the `spawn` backend: a delegated task runs as a fresh child -# agent on this same process. (fork is available too — load dsh-subagent-fork -# and a second dsh-tool-subagent bound to it with a distinct toolName.) +# The subagent seam + BOTH in-process backends + two model-facing tools, as leaf +# entries after the app (which provides ctx.agents/ctx.tools). spawn (a fresh +# child) and fork (a child seeded with the parent's completed-turn prefix) are +# independent backends over the shared dsh-subagent-inprocess driver. Exposing +# both transports is pure config: load each backend, then load dsh-tool-subagent +# once per backend with a distinct toolName (the tool registry rejects a +# duplicate name) — no code change. - id: subagent name: '@deepseek-ai/dsh-subagent' @@ -76,7 +80,19 @@ config: providerName: spawn +- id: subagent-fork + name: '@deepseek-ai/dsh-subagent-fork' + config: + providerName: fork + - id: tool-subagent name: '@deepseek-ai/dsh-tool-subagent' config: provider: spawn + toolName: subagent + +- id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork diff --git a/packages/subagent/README.md b/packages/subagent/README.md index 582172dfd1..85ba55b626 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -5,10 +5,11 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](.. | Package | Role | ctx key | |---|---|---| | `subagent/` | Abstract subagent seam: named-provider registry + vocabulary | `ctx.subagents` | -| `subagent-spawn/` | In-process backend: a fresh child agent (+ the shared run driver) | (registers on `ctx.subagents`) | +| `subagent-inprocess/` | Shared in-process run driver (pure lib; registers nothing) | — | +| `subagent-spawn/` | In-process backend: a fresh child agent | (registers on `ctx.subagents`) | | `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) | | `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | -The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends ship here; the out-of-process `dsh-subagent-acp` and the test-only `dsh-subagent-mock` (in [support](../support/README.md)) are separate. All **product** packages except the mock. +The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a pure library — both depend on it, neither on the other) and ship here; the out-of-process `dsh-subagent-acp` and the test-only `dsh-subagent-mock` (in [support](../support/README.md)) are separate. All **product** packages except the mock. The proposal and design rationale: [docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md](../../docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md). diff --git a/packages/subagent/subagent-fork/package.json b/packages/subagent/subagent-fork/package.json index 44027b4b0c..3348a77dd0 100644 --- a/packages/subagent/subagent-fork/package.json +++ b/packages/subagent/subagent-fork/package.json @@ -23,7 +23,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", - "@deepseek-ai/dsh-subagent-spawn": "^0.0.1", + "@deepseek-ai/dsh-subagent-inprocess": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "dependencies": { @@ -36,6 +36,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts index 6c730e225e..02c1811d82 100644 --- a/packages/subagent/subagent-fork/src/index.ts +++ b/packages/subagent/subagent-fork/src/index.ts @@ -2,8 +2,10 @@ * 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. Shares the run driver with - * `@deepseek-ai/dsh-subagent-spawn`; the only difference is the seed. + * 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 @@ -23,7 +25,7 @@ import z from 'schemastery' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' -import { startInProcessRun } from '@deepseek-ai/dsh-subagent-spawn' +import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' export const name = 'subagent-fork' export const inject = ['subagents', 'agents'] diff --git a/packages/subagent/subagent-fork/tsconfig.json b/packages/subagent/subagent-fork/tsconfig.json index d05e0f6081..bf2abbe698 100644 --- a/packages/subagent/subagent-fork/tsconfig.json +++ b/packages/subagent/subagent-fork/tsconfig.json @@ -27,7 +27,7 @@ "path": "../subagent" }, { - "path": "../subagent-spawn" + "path": "../subagent-inprocess" } ] } diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md new file mode 100644 index 0000000000..af6d5792a9 --- /dev/null +++ b/packages/subagent/subagent-inprocess/README.md @@ -0,0 +1,28 @@ +# @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](../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. 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`](../../core/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); +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 system prompt is NOT inherited); +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); +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`. + +`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. + +### `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`. diff --git a/packages/subagent/subagent-inprocess/package.json b/packages/subagent/subagent-inprocess/package.json new file mode 100644 index 0000000000..c2f6896c62 --- /dev/null +++ b/packages/subagent/subagent-inprocess/package.json @@ -0,0 +1,40 @@ +{ + "name": "@deepseek-ai/dsh-subagent-inprocess", + "description": "Shared in-process subagent run driver: drives a child agent on ctx.agents (used by the spawn and fork backends)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-subagent": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/subagent/subagent-spawn/src/in-process.ts b/packages/subagent/subagent-inprocess/src/index.ts similarity index 91% rename from packages/subagent/subagent-spawn/src/in-process.ts rename to packages/subagent/subagent-inprocess/src/index.ts index e121611697..d2840881af 100644 --- a/packages/subagent/subagent-spawn/src/in-process.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -1,15 +1,16 @@ /** - * The shared in-process subagent run driver. A subagent backend that runs the - * 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. Both in-process backends use this: - * `@deepseek-ai/dsh-subagent-spawn` (a fresh child) and - * `@deepseek-ai/dsh-subagent-fork` (a child seeded with a prefix of the - * parent's log) differ ONLY in the `seed` they pass — everything downstream - * (drive the child, read its final output, map the stop reason, dispose) is - * identical and lives here. + * 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. * - * @module @deepseek-ai/dsh-subagent-spawn/in-process + * This package owns no provider and registers nothing; it is a pure library the + * backend packages depend on, so neither backend needs to know about the other. + * + * @module @deepseek-ai/dsh-subagent-inprocess */ import { randomUUID } from 'node:crypto' diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts new file mode 100644 index 0000000000..7219e03988 --- /dev/null +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry, { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import * as Invariants from '@deepseek-ai/dsh-invariants' +import SubagentService from '@deepseek-ai/dsh-subagent' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { depthOf, SubagentDepthError, startInProcessRun } from '../src/index.ts' + +type Script = ConstructorParameters[0] + +/** + * Drives the shared in-process run driver DIRECTLY (no provider package), so the + * driver's own contract — depth read/cap, the one-shot drive, the result read — + * is covered independently of which backend (spawn/fork) calls it. The only + * mocked boundary is the model; the real agent loop, SubagentService, and + * dsh-invariants are mounted, so a malformed child session log fails the test. + */ +async function setup(script: Script) { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(Invariants) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) + const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) + return { ctx, parent } +} + +function text(blocks: { type: string; text?: string }[]): string { + return blocks.filter(b => b.type === 'text').map(b => b.text).join('') +} + +describe('depthOf', () => { + it('reads 0 for an agent with no subagentDepth, the set value otherwise', async () => { + const { parent } = await setup([]) + expect(depthOf(parent)).toBe(0) + const withDepth = { options: { subagentDepth: 3 } } as unknown as Agent + expect(depthOf(withDepth)).toBe(3) + }) +}) + +describe('startInProcessRun', () => { + it('drives a fresh child (no seed) to completion and returns its output', async () => { + const { ctx, parent } = await setup([textResponse('driver child answer')]) + const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'do X' }], parent }, { providerName: 'spawn' }) + const result = await run.result + expect(result.stopReason).toBe('completed') + expect(text(result.output)).toBe('driver child answer') + expect(depthOf(ctx.agents.get(run.id)!)).toBe(1) + await run.dispose() + }) + + it('throws SubagentDepthError when the child would exceed maxDepth', async () => { + const { ctx, parent } = await setup([]) + expect(() => startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }, { providerName: 'spawn' })) + .toThrow(SubagentDepthError) + }) + + it('seeds the child session when a seed is supplied', async () => { + // Drive the parent through one real turn, then seed the child with that + // completed-turn prefix — the child must SEE the parent's history but its + // result is scoped to its OWN events (not the seeded parent message). + const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('seeded child reply')]) + parent.send([{ type: 'text', text: 'parent q' }]) + await parent.whenIdle() + const seed = parent.session.events.slice() + const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'child q' }], parent }, { providerName: 'fork', seed }) + const result = await run.result + expect(result.stopReason).toBe('completed') + expect(text(result.output)).toBe('seeded child reply') + const child = ctx.agents.get(run.id)! + // The child inherited the parent's prefix. + expect(child.session.events.slice(0, seed.length).some(e => e.type === 'user/message')).toBe(true) + await run.dispose() + }) +}) diff --git a/packages/subagent/subagent-inprocess/tsconfig.json b/packages/subagent/subagent-inprocess/tsconfig.json new file mode 100644 index 0000000000..f90eba8f7e --- /dev/null +++ b/packages/subagent/subagent-inprocess/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../subagent" + } + ] +} diff --git a/packages/subagent/subagent-spawn/README.md b/packages/subagent/subagent-spawn/README.md index 4e6e22f68d..97dfae9304 100644 --- a/packages/subagent/subagent-spawn/README.md +++ b/packages/subagent/subagent-spawn/README.md @@ -2,17 +2,11 @@ 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. -It also exports the **shared in-process run driver** (`startInProcessRun`) that the [fork](../subagent-fork/README.md) backend builds on — spawn and fork differ only in the session seed. +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)` → -1. computes child depth = `depthOf(parent) + 1`; if `request.maxDepth` is set and exceeded, throws `SubagentDepthError` (the `depthLimit` capability); -2. creates a child via `ctx.agents.create` with a fresh `AgentId`/`SessionId`, the parent's `cwd` + `parentSession` lineage, 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 system prompt is NOT inherited); -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); -4. reads the result: the last `assistant/message` content (deep-cloned — the log is frozen) and the last `turn/end.reason` mapped to a `SubagentStopReason`. - -`dispose()` delegates to `AgentHandle.dispose()` (stop loop → await quiescence → remove session); `cancel()` cancels the child's in-flight turn. +`start(request)` delegates to `startInProcessRun(ctx, request, { providerName })` with no seed: a fresh child agent with the parent's `cwd`/`parentSession` lineage and (by default) the parent's model. See the [driver README](../subagent-inprocess/README.md) for the full lifecycle (depth check, one-shot drive, result read, dispose). ## Capabilities @@ -23,7 +17,3 @@ It also exports the **shared in-process run driver** (`startInProcessRun`) that | Key | Meaning | |---|---| | `providerName` | Registry name on `ctx.subagents` (default `spawn`). | - -## Depth tracking - -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`. Read it with the exported `depthOf(agent)`. diff --git a/packages/subagent/subagent-spawn/package.json b/packages/subagent/subagent-spawn/package.json index 184296f01a..359d91e962 100644 --- a/packages/subagent/subagent-spawn/package.json +++ b/packages/subagent/subagent-spawn/package.json @@ -20,10 +20,8 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", + "@deepseek-ai/dsh-subagent-inprocess": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "dependencies": { @@ -38,6 +36,7 @@ "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", diff --git a/packages/subagent/subagent-spawn/src/index.ts b/packages/subagent/subagent-spawn/src/index.ts index bbfcc03719..2ea082e20a 100644 --- a/packages/subagent/subagent-spawn/src/index.ts +++ b/packages/subagent/subagent-spawn/src/index.ts @@ -5,9 +5,9 @@ * context). The cheapest transport, reusing the agent factory's quiescent * teardown. * - * The fork sibling (`@deepseek-ai/dsh-subagent-fork`) shares this package's run - * driver ({@link startInProcessRun}) and differs ONLY in seeding the child with - * a prefix of the parent's log. + * 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. * * Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default. * @@ -17,10 +17,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' -import { startInProcessRun } from './in-process.ts' - -export { startInProcessRun, depthOf, SubagentDepthError } from './in-process.ts' -export type { InProcessRunOptions } from './in-process.ts' +import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' export const name = 'subagent-spawn' export const inject = ['subagents', 'agents'] diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index c57819d86a..ccfd6492f4 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -12,7 +12,7 @@ import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService from '@deepseek-ai/dsh-subagent' import { MockAdapter, maxTokensResponse, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as spawn from '../src/index.ts' -import { depthOf, SubagentDepthError } from '../src/in-process.ts' +import { depthOf, SubagentDepthError } from '@deepseek-ai/dsh-subagent-inprocess' type Script = ConstructorParameters[0] diff --git a/packages/subagent/subagent-spawn/tsconfig.json b/packages/subagent/subagent-spawn/tsconfig.json index 5e6c9f9100..dc7b5cc8cd 100644 --- a/packages/subagent/subagent-spawn/tsconfig.json +++ b/packages/subagent/subagent-spawn/tsconfig.json @@ -17,17 +17,11 @@ { "path": "../../../vendor/schemastery" }, - { - "path": "../../core/agent" - }, - { - "path": "../../llm/llm" - }, - { - "path": "../../core/session" - }, { "path": "../subagent" + }, + { + "path": "../subagent-inprocess" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b262257b14..bd7133ca0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -360,6 +360,9 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent + '@deepseek-ai/dsh-subagent-inprocess': + specifier: workspace:^ + version: link:../subagent-inprocess '@deepseek-ai/dsh-subagent-spawn': specifier: workspace:^ version: link:../subagent-spawn @@ -373,6 +376,36 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/subagent/subagent-inprocess: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../subagent + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/subagent/subagent-spawn: dependencies: schemastery: @@ -406,6 +439,9 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent + '@deepseek-ai/dsh-subagent-inprocess': + specifier: workspace:^ + version: link:../subagent-inprocess '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt diff --git a/tsconfig.build.json b/tsconfig.build.json index 4f0528961d..d9b00156b3 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -35,6 +35,7 @@ { "path": "./packages/subagent/subagent" }, { "path": "./packages/support/subagent-mock" }, { "path": "./packages/subagent/tool-subagent" }, + { "path": "./packages/subagent/subagent-inprocess" }, { "path": "./packages/subagent/subagent-spawn" }, { "path": "./packages/subagent/subagent-fork" } ] From 67c7ef791f323036ffc0a4e20fcc708b1edeb73b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:43:51 +0800 Subject: [PATCH 5/5] Apply the ts-build-config (lib/types) convention to the new subagent packages Master's #36 moved declaration output to lib/types (and types/exports/files point there). The merge applied that to all pre-existing packages, but the subagent backends introduced on this stack (subagent-inprocess, subagent-spawn, subagent-fork) still used the old lib/ layout. Bring them onto the new convention and add them to the single typecheck tsconfig.json references. --- packages/subagent/subagent-fork/package.json | 8 +++++--- packages/subagent/subagent-fork/tsconfig.json | 2 +- packages/subagent/subagent-inprocess/package.json | 8 +++++--- packages/subagent/subagent-inprocess/tsconfig.json | 2 +- packages/subagent/subagent-spawn/package.json | 8 +++++--- packages/subagent/subagent-spawn/tsconfig.json | 2 +- tsconfig.json | 5 ++++- 7 files changed, 22 insertions(+), 13 deletions(-) diff --git a/packages/subagent/subagent-fork/package.json b/packages/subagent/subagent-fork/package.json index 3348a77dd0..7b1c40c4f3 100644 --- a/packages/subagent/subagent-fork/package.json +++ b/packages/subagent/subagent-fork/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/subagent/subagent-fork/tsconfig.json b/packages/subagent/subagent-fork/tsconfig.json index bf2abbe698..bac12550af 100644 --- a/packages/subagent/subagent-fork/tsconfig.json +++ b/packages/subagent/subagent-fork/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/packages/subagent/subagent-inprocess/package.json b/packages/subagent/subagent-inprocess/package.json index c2f6896c62..f3bd774554 100644 --- a/packages/subagent/subagent-inprocess/package.json +++ b/packages/subagent/subagent-inprocess/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/subagent/subagent-inprocess/tsconfig.json b/packages/subagent/subagent-inprocess/tsconfig.json index f90eba8f7e..4cb435d4fb 100644 --- a/packages/subagent/subagent-inprocess/tsconfig.json +++ b/packages/subagent/subagent-inprocess/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/packages/subagent/subagent-spawn/package.json b/packages/subagent/subagent-spawn/package.json index 359d91e962..087371ded2 100644 --- a/packages/subagent/subagent-spawn/package.json +++ b/packages/subagent/subagent-spawn/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/subagent/subagent-spawn/tsconfig.json b/packages/subagent/subagent-spawn/tsconfig.json index dc7b5cc8cd..219bf2a0c9 100644 --- a/packages/subagent/subagent-spawn/tsconfig.json +++ b/packages/subagent/subagent-spawn/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/tsconfig.json b/tsconfig.json index caf73078cd..3cb9a7e4a7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -45,6 +45,9 @@ { "path": "./packages/support/llm-replay" }, { "path": "./packages/subagent/subagent" }, { "path": "./packages/support/subagent-mock" }, - { "path": "./packages/subagent/tool-subagent" } + { "path": "./packages/subagent/tool-subagent" }, + { "path": "./packages/subagent/subagent-inprocess" }, + { "path": "./packages/subagent/subagent-spawn" }, + { "path": "./packages/subagent/subagent-fork" } ] }