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" } ] }