Merge remote-tracking branch 'origin/master' into session-surface
Reconciles the session-surface work (surfaceOp/sourceEventSeqs provenance as the sole derivation path) with master's worktree-subagent series (fork-seed boundary + out-of-process subagent backends). Semantic reconciliations beyond the textual auto-merge: - SQLite SCHEMA_VERSION: both sides bumped 2->3. Merged to a single v3 carrying BOTH column families — master's seed_length on `sessions` and surface's source_event_seqs/surface_op on `events`. writeRow + both INSERT sites bind the full set; the schema doc lists all three added columns as the v2->v3 gap. - agent-loop runStep request: master's `sessionId: session.id` and surface's per-append surfaceOp/sourceEventSeqs coexist (different regions). - Fork seed + surface: a fork seeds the child from the parent's LIVE events, which now carry surfaceOp, so the child's surface rebuilds correctly. Verified end-to-end — the subagent-fork replay recalls the inherited "SAFFRON" codeword through the seeded prefix. - Subagent snapshot fixtures (recorded pre-surface) re-enriched via KEYLESS deterministic replay: only surfaceOp/sourceEventSeqs added onto existing recorded lines (matched by seq), no recorded value changed. Not re-recorded against the live API. Gates: typecheck, test (1112), test:snapshot (14), doc-sync, lint, build, hygiene all green.
This commit is contained in:
@@ -11,6 +11,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
|
||||
| [`core/`](core/README.md) | Product API spine: session, system-prompt, tools, agent, and the concrete loop | Product — stable surface |
|
||||
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
|
||||
| [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface |
|
||||
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
|
||||
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
|
||||
| [`ui/`](ui/README.md) | Editor/client integration surfaces (the ACP bridge) | Product — stable surface |
|
||||
| [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, stdio UI, replay adapter) | Support — lower compatibility expectations |
|
||||
@@ -37,6 +38,12 @@ dsh-invariants ← dsh-llm, dsh-session, dsh-agent (dev-mode contract checks)
|
||||
dsh-acp ← dsh-agent, dsh-llm, dsh-session, dsh-session-persistence (ACP JSON-RPC bridge)
|
||||
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-subagent-acp ← dsh-subagent, dsh-agent, dsh-llm, @agentclientprotocol/sdk (out-of-process child over ACP)
|
||||
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)
|
||||
dsh-acp-agent ← dsh-agent-core, dsh-acp, dsh-session-persistence-jsonl (ACP server APP + bin)
|
||||
@@ -69,6 +76,12 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop
|
||||
| `acp-agent/` | `ui` | ACP server APP: agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) |
|
||||
| `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-acp/` | `subagent` | Out-of-process backend: a child agent in a spawned subprocess, driven over the Agent Client Protocol | (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<B>` nominal-typing primitive (no runtime code, no harness deps) | (none — type-only) |
|
||||
|
||||
Each package has its own `README.md` with purpose, service API, events, extension points, and deliberate non-goals (TODOs).
|
||||
@@ -79,5 +92,5 @@ Each package has its own `README.md` with purpose, service API, events, extensio
|
||||
- **Declaration merging for events and ctx**: services declare their events in `declare module 'cordis' { interface Events { ... } }` and their ctx key in `interface Context`.
|
||||
- **Waterfall semantics**: `ctx.waterfall` listeners receive `(...args, next)` and MUST call `next()` to delegate; returning without it short-circuits (the veto mechanism).
|
||||
- **Extensible unions**: `ContentBlockMap`, `MessageSourceMap`, `FinishReasonMap`, `TurnTriggerMap`, `TurnEndReasonMap`, and `SessionEventMap` use the merge-extensible-map pattern so plugins can add variants via declaration merging.
|
||||
- **ESM everywhere**; imports use package names across package boundaries, `.ts` extensions within a package.
|
||||
- **ESM everywhere**; imports use package names across package boundaries and explicit `.ts` relative specifiers within a package.
|
||||
- **Tests**: vitest, colocated under `packages/<group>/<pkg>/tests/*.spec.ts`. Every registry needs an HMR-safety test. Err on the side of more tests.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -140,16 +136,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)
|
||||
}
|
||||
|
||||
@@ -217,6 +219,9 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
createdAt: meta.createdAt,
|
||||
...meta.cwd !== undefined ? { cwd: meta.cwd } : {},
|
||||
...meta.parentSession !== undefined ? { parentSession: meta.parentSession } : {},
|
||||
// Reconstruct the seed boundary from the persisted header, NOT from
|
||||
// `events.length` (the resume seeds the WHOLE stored log).
|
||||
...meta.seedLength !== undefined ? { seedLength: meta.seedLength } : {},
|
||||
},
|
||||
})
|
||||
return this.startOwned(options.agentId, options.agentOptions ?? {}, session)
|
||||
|
||||
@@ -570,6 +570,7 @@ async function runStep(
|
||||
messages: session.deriveMessages(),
|
||||
...system ? { system } : {},
|
||||
...assembly.tools.length > 0 ? { tools: assembly.tools } : {},
|
||||
sessionId: session.id,
|
||||
signal,
|
||||
}
|
||||
request = await ctx.waterfall('agent/request', agent, turn, step, request, () => Promise.resolve(request))
|
||||
|
||||
@@ -94,9 +94,9 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resume of a forked session preserves the parentSession lineage in the header', async () => {
|
||||
// Lifecycle 1: persist a FORKED session (carries parentSession in its
|
||||
// header) by creating it with a complete-turn seed — the write path
|
||||
it('resume of a forked session preserves the parentSession lineage and seed boundary in the header', async () => {
|
||||
// Lifecycle 1: persist a FORKED session (carries parentSession + seedLength
|
||||
// in its header) by creating it with a complete-turn seed — the write path
|
||||
// materializes the fork (header + seed) on disk.
|
||||
const seed: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
@@ -104,12 +104,18 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
]
|
||||
const adapter1 = new MockAdapter([textResponse('a')])
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const forked = ctx1.sessions.create(SessionId('forked-sess'), { seed, meta: { cwd: '/w', parentSession: SessionId('parent-sess') } })
|
||||
const forked = ctx1.sessions.create(SessionId('forked-sess'), {
|
||||
seed,
|
||||
meta: { cwd: '/w', parentSession: SessionId('parent-sess'), seedLength: seed.length },
|
||||
})
|
||||
await ctx1.parallel('session/flush', forked)
|
||||
await ctx1.fiber.dispose()
|
||||
|
||||
// Lifecycle 2: resume it; the parentSession header survives the round-trip
|
||||
// (exercises resume's parentSession-present branch).
|
||||
// Lifecycle 2: resume it; the parentSession + seedLength header survives the
|
||||
// round-trip (exercises resume's parentSession- and seedLength-present
|
||||
// branches). seedLength must come from the PERSISTED header, not from the
|
||||
// resume seed length (which is the whole stored log, not the original
|
||||
// boundary).
|
||||
const adapter2 = new MockAdapter([textResponse('b')])
|
||||
const ctx2 = new Context()
|
||||
await ctx2.plugin(LlmService)
|
||||
@@ -123,6 +129,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('forked-sess') })).agent as ReactLoopAgent
|
||||
expect(a2.session.header.parentSession).toBe('parent-sess')
|
||||
expect(a2.session.header.cwd).toBe('/w')
|
||||
expect(a2.session.header.seedLength).toBe(seed.length)
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
@@ -30,13 +30,25 @@ export interface CreateAgentOptions {
|
||||
/** The live session's id (NOT derived from agentId). */
|
||||
sessionId: SessionId
|
||||
/**
|
||||
* Session creation metadata: validated absolute `cwd` and `parentSession`
|
||||
* fork lineage. Mirrors the `cwd`/`parentSession` fields of
|
||||
* Session creation metadata: validated absolute `cwd`, `parentSession`
|
||||
* fork lineage, and the `seedLength` seed boundary. Mirrors the
|
||||
* `cwd`/`parentSession`/`seedLength` fields of
|
||||
* {@link CreateSessionOptions.meta} in dsh-session (the internal-only
|
||||
* `createdAt`, used when reconstructing a persisted session, is deliberately
|
||||
* excluded — a factory caller never sets it).
|
||||
*/
|
||||
meta?: { cwd?: string; parentSession?: SessionId }
|
||||
meta?: { cwd?: string; parentSession?: SessionId; seedLength?: number }
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
@@ -118,11 +118,12 @@ export interface Agent {
|
||||
*/
|
||||
whenIdle(): Promise<void>
|
||||
|
||||
// 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' {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -360,6 +360,7 @@ export class SessionStore extends Service {
|
||||
createdAt: options?.meta?.createdAt ?? Date.now(),
|
||||
...cwd !== undefined ? { cwd } : {},
|
||||
...options?.meta?.parentSession !== undefined ? { parentSession: options.meta.parentSession } : {},
|
||||
...options?.meta?.seedLength !== undefined ? { seedLength: options.meta.seedLength } : {},
|
||||
}
|
||||
return new Session(sessionId, options?.seed, header)
|
||||
}
|
||||
|
||||
@@ -50,6 +50,16 @@ export interface SessionHeader {
|
||||
cwd?: string
|
||||
/** The session this one was forked from (seed lineage), if any. */
|
||||
parentSession?: SessionId
|
||||
/**
|
||||
* How many leading events were INHERITED via a seed rather than produced by
|
||||
* this session — the seed boundary. Set when a fork seeds a child with a
|
||||
* prefix of the parent's log (= the seeded prefix length); absent/0 means the
|
||||
* session produced all its own events. Persisted so a reload reconstructs the
|
||||
* boundary instead of re-deriving it from the full stored log, and so a replay
|
||||
* harness can skip the inherited prefix when deriving the child's OWN script
|
||||
* (the seeded events are the parent's, not this child's model calls).
|
||||
*/
|
||||
seedLength?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,10 +73,16 @@ export interface CreateSessionOptions {
|
||||
/**
|
||||
* Creation metadata. The store fills in `version`/`id` and defaults
|
||||
* `createdAt` to now; the caller supplies the storage-level fields (validated
|
||||
* absolute `cwd`, `parentSession` lineage, and — when reconstructing a
|
||||
* persisted session — the original `createdAt` to preserve it).
|
||||
* absolute `cwd`, `parentSession` lineage, the seed boundary `seedLength`, and
|
||||
* — when reconstructing a persisted session — the original `createdAt` to
|
||||
* preserve it).
|
||||
*
|
||||
* `seedLength` is EXPLICIT, not inferred from `seed.length`: a reconstruction
|
||||
* (resume/load) seeds the WHOLE stored log, so its `seed.length` is the full
|
||||
* length, not the original boundary — the caller must pass the persisted
|
||||
* boundary back. A fresh fork passes its actual seeded-prefix length.
|
||||
*/
|
||||
meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number }
|
||||
meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { CallId } from './brand.ts'
|
||||
|
||||
/** Cache hint attached to a content block (provider-interpreted). */
|
||||
@@ -192,4 +193,18 @@ export interface GenerateOptions {
|
||||
*/
|
||||
stop?: string[]
|
||||
signal?: AbortSignal
|
||||
/**
|
||||
* The id of the session this request belongs to — stamped by the agent loop
|
||||
* from `agent.session.id`. Adapters ignore it; it lets an `llm/stream` listener
|
||||
* route a call by WHICH session issued it (the replay adapter keys its per-call
|
||||
* cursor by session, so a parent and its in-process subagent — each with its
|
||||
* own session on one context — replay from their own recorded scripts).
|
||||
*
|
||||
* Typed as `Branded<'SessionId'>` rather than importing `SessionId` from
|
||||
* `dsh-session`: that package imports `Message` from here, so importing its
|
||||
* `SessionId` back would cycle. `SessionId` IS `Branded<'SessionId'>`, so a
|
||||
* real session id assigns with no cast. (A future ids package could own the
|
||||
* brand and dissolve this note.)
|
||||
*/
|
||||
sessionId?: Branded<'SessionId'>
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface HeaderLine {
|
||||
createdAt: number
|
||||
cwd?: string
|
||||
parentSession?: SessionId
|
||||
seedLength?: number
|
||||
}
|
||||
|
||||
/** Build the header line object from a {@link SessionHeader}. */
|
||||
@@ -35,6 +36,7 @@ export function toHeaderLine(header: SessionHeader): HeaderLine {
|
||||
createdAt: header.createdAt,
|
||||
...header.cwd !== undefined ? { cwd: header.cwd } : {},
|
||||
...header.parentSession !== undefined ? { parentSession: header.parentSession } : {},
|
||||
...header.seedLength !== undefined ? { seedLength: header.seedLength } : {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +48,7 @@ export function fromHeaderLine(line: HeaderLine): SessionHeader {
|
||||
createdAt: line.createdAt,
|
||||
...line.cwd !== undefined ? { cwd: line.cwd } : {},
|
||||
...line.parentSession !== undefined ? { parentSession: line.parentSession } : {},
|
||||
...line.seedLength !== undefined ? { seedLength: line.seedLength } : {},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -241,19 +241,21 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
*/
|
||||
private writeRow(meta: SessionHeader): void {
|
||||
this.db.prepare(`
|
||||
INSERT INTO sessions (id, version, created_at, cwd, parent_session)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
version = excluded.version,
|
||||
created_at = excluded.created_at,
|
||||
cwd = excluded.cwd,
|
||||
parent_session = excluded.parent_session
|
||||
parent_session = excluded.parent_session,
|
||||
seed_length = excluded.seed_length
|
||||
`).run(
|
||||
meta.id,
|
||||
meta.version,
|
||||
meta.createdAt,
|
||||
meta.cwd ?? null,
|
||||
meta.parentSession ?? null,
|
||||
meta.seedLength ?? null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface SessionRow {
|
||||
created_at: number
|
||||
cwd: string | null
|
||||
parent_session: string | null
|
||||
seed_length: number | null
|
||||
}
|
||||
|
||||
/** An `events` table row: one `SessionEvent` mapped 1:1 (`data` is JSON text). */
|
||||
@@ -55,8 +56,9 @@ export interface EventRow {
|
||||
* current {@link SCHEMA_VERSION}; an existing database whose version is NOT the
|
||||
* current one (written by a different, incompatible build — older or newer) is
|
||||
* REJECTED rather than opened against a layout this build does not understand.
|
||||
* There are no migrations: v1 had a different `sessions` layout and is not
|
||||
* upgraded in place.
|
||||
* There are no migrations: an earlier layout (v1's different `sessions` shape,
|
||||
* v2 without the `seed_length`/`source_event_seqs`/`surface_op` columns) is not
|
||||
* upgraded in place — it is rejected.
|
||||
*/
|
||||
export function openDatabase(path: string): DatabaseSync {
|
||||
const db = new DatabaseSync(path)
|
||||
@@ -80,7 +82,8 @@ export function openDatabase(path: string): DatabaseSync {
|
||||
version INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
cwd TEXT,
|
||||
parent_session TEXT
|
||||
parent_session TEXT,
|
||||
seed_length INTEGER
|
||||
) STRICT
|
||||
`)
|
||||
db.exec(`
|
||||
@@ -106,6 +109,7 @@ export function rowToMeta(row: SessionRow): SessionHeader {
|
||||
createdAt: row.created_at,
|
||||
...row.cwd !== null ? { cwd: row.cwd } : {},
|
||||
...row.parent_session !== null ? { parentSession: row.parent_session as SessionId } : {},
|
||||
...row.seed_length !== null ? { seedLength: row.seed_length } : {},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -123,6 +123,26 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
}
|
||||
})
|
||||
|
||||
it('round-trips the seed boundary (seedLength) through persistence', async () => {
|
||||
// A forked child records how many leading events were inherited via the
|
||||
// seed; the boundary must survive a reload (so a resume/replay can tell the
|
||||
// inherited prefix from the child's own events). Both backends carry it on
|
||||
// the header — JSONL on the header line, SQLite in the seed_length column.
|
||||
const fix = await makeFixture()
|
||||
const { ctx, fiber } = await freshCtx(fix)
|
||||
try {
|
||||
const session = ctx.sessions.create(SessionId('forked-child'), { meta: { cwd: WORK, seedLength: 3 } })
|
||||
send(session, oneTurnLog())
|
||||
await ctx.parallel('session/flush', session)
|
||||
|
||||
const loaded = await ctx.sessionPersistence.load(SessionId('forked-child'))
|
||||
expect(loaded.meta.seedLength).toBe(3)
|
||||
} finally {
|
||||
await fiber.dispose()
|
||||
await fix.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('snapshot-on-buffer: mutating an event after session/event does not corrupt the persisted copy', async () => {
|
||||
const fix = await makeFixture()
|
||||
const { ctx, fiber } = await freshCtx(fix)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
|
||||
16
packages/subagent/README.md
Normal file
16
packages/subagent/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# subagent/ — subagent capability family
|
||||
|
||||
The subagent seam: an agent delegating work to a child agent. Like the [bash](../bash/README.md) and [llm](../llm/README.md) families this is a capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)) — but with one defining difference: **multiple provider implementations coexist in one context**, registered by name, rather than the single-implementation bash shape. The registry mirrors the LLM adapter registry.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `subagent/` | Abstract subagent seam: named-provider registry + vocabulary | `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`) |
|
||||
| `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP | (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 share the `subagent-inprocess` driver (a pure library — both depend on it, neither on the other), and the out-of-process `subagent-acp` backend ships alongside them here; the test-only `dsh-subagent-mock` (in [support](../support/README.md)) is separate. All **product** packages except the mock.
|
||||
|
||||
The proposal and design rationale: [docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md](../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md).
|
||||
69
packages/subagent/subagent-acp/README.md
Normal file
69
packages/subagent/subagent-acp/README.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# @deepseek-ai/dsh-subagent-acp
|
||||
|
||||
The out-of-process **ACP subagent backend**: runs each child agent in a spawned subprocess, driven over the [Agent Client Protocol](https://github.com/zed-industries/agent-client-protocol) (ACP) as the *client*. Registers a `SubagentProvider` on `ctx.subagents` (the [subagent seam](../subagent)), alongside the in-process [`-spawn`](../subagent-spawn)/[`-fork`](../subagent-fork) backends — multiple backends coexist by name.
|
||||
|
||||
It is the direction-inverted twin of the server-side bridge in [`@deepseek-ai/dsh-acp`](../../ui/acp): that package is the ACP *agent* (it answers `initialize`/`newSession`/`prompt`); this one is the ACP *client* (it *calls* them and implements the `sessionUpdate`/`requestPermission` callbacks). Point the configured command at the `acp-agent` example to "talk to our own process".
|
||||
|
||||
## What it does
|
||||
|
||||
`start(request)` spawns the configured command, wraps its stdio in an ACP `ClientSideConnection`, and drives one session: `initialize` → `newSession` → `prompt`. The child's streamed `agent_message_chunk` text becomes the `SubagentResult.output`; the prompt's terminal `StopReason` maps to the stop reason. `dispose()` kills the subprocess and awaits its exit.
|
||||
|
||||
**Fresh process per run.** Each `start` spawns a new child, runs exactly one ACP session, and disposes it. Persistent-process pooling is a future optimization (see the RFC).
|
||||
|
||||
Unlike the in-process backends, the child does NOT share this cordis context — it is a separate process with its own session, model client, and tools. So this backend:
|
||||
- injects only `subagents` (no `ctx.agents`);
|
||||
- advertises NO start-time capabilities (an out-of-process child can't enforce the parent's depth/tool-filter);
|
||||
- ignores `request.parent`.
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Type | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `providerName` | string | `acp` | Registry name on `ctx.subagents`. |
|
||||
| `command` | string | — (required) | The executable to spawn for each run (the child ACP agent). |
|
||||
| `args` | string[] | `[]` | Arguments passed to `command`. |
|
||||
| `cwd` | string | parent cwd | Working directory for the child process and its ACP session. |
|
||||
| `permission` | `'allow' \| 'reject'` | `reject` | How to auto-answer the child's `session/request_permission` prompts. `reject` declines every prompt (answer `cancelled`); `allow` approves via the first allow-shaped option. The first cut surfaces no prompt to a human. |
|
||||
| `env` | Record<string,string> | `{}` | Extra env vars for the child (e.g. its own `DEEPSEEK_API_KEY`). Forwarded on top of a credential-scrubbed copy of the parent env, so an explicit key reaches the child while ambient secrets do not leak implicitly. |
|
||||
|
||||
```yaml
|
||||
- id: subagent-acp
|
||||
name: '@deepseek-ai/dsh-subagent-acp'
|
||||
config:
|
||||
providerName: acp
|
||||
command: node
|
||||
args: ['--import', 'tsx', './packages/ui/acp-agent/src/bin.ts', './examples/acp-agent/cordis.yml']
|
||||
permission: reject
|
||||
env:
|
||||
DEEPSEEK_API_KEY: !!js process.env.DEEPSEEK_API_KEY
|
||||
```
|
||||
|
||||
## StopReason mapping
|
||||
|
||||
ACP `StopReason` → harness `SubagentStopReason`:
|
||||
|
||||
| ACP | harness |
|
||||
|---|---|
|
||||
| `end_turn` | `completed` |
|
||||
| `max_tokens` | `max-tokens` |
|
||||
| `refusal` | `refusal` |
|
||||
| `cancelled` | `aborted` |
|
||||
| `max_turn_requests` | `error` (no clean equivalent; the task did not finish) |
|
||||
| _(unknown)_ | `error` |
|
||||
|
||||
A spawn/transport/RPC failure resolves `error` (or `aborted` if a cancel was requested) — `result` never rejects on a child-level failure, per the seam contract.
|
||||
|
||||
## Environment scrub
|
||||
|
||||
Credential-shaped ambient vars (`/KEY|SECRET|TOKEN/i`) are NOT forwarded to the child by default — the parent harness's own secrets must not leak into a spawned process implicitly. The child's OWN credentials are supplied explicitly via `config.env`, layered AFTER the scrub, so an intended `DEEPSEEK_API_KEY` survives while an incidental `AWS_SECRET_ACCESS_KEY` does not.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Keyless** (`subagent-acp.spec.ts`): spawns a scripted mock ACP server subprocess (`tests/mock-acp-server.ts`) and drives it through the real backend over real ACP stdio — connection setup, client callbacks, the prompt round-trip, stop-reason mapping, cancellation (including the early-cancel race and a torn-pipe-after-cancel), permission auto-answer, and quiescent disposal. No model, no key.
|
||||
- **With-key e2e** (`subagent-acp.e2e.ts`): the harness drives ITSELF — the backend spawns the real `acp-agent` example process and a real model in that child answers a prompt and does real file work (verified on disk). Self-skips without `DEEPSEEK_API_KEY`.
|
||||
|
||||
`TODO(acp-subagent-replay)`: snapshot-tier coverage of an ACP child is a separate replay shape (each child is its own PROCESS with its own single-agent replay, distinct from the in-process per-session keying), deferred — see the RFC.
|
||||
|
||||
## Plugin export shape
|
||||
|
||||
Named `name` / `inject` / `Config` / `apply`, with **no default export**: the cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray default would collapse the module to the bare function and drop the `inject` namespace (see [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)).
|
||||
41
packages/subagent/subagent-acp/package.json
Normal file
41
packages/subagent/subagent-acp/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-subagent-acp",
|
||||
"description": "Out-of-process ACP subagent backend: drives a child agent in a spawned subprocess over the Agent Client Protocol",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.25.1",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
95
packages/subagent/subagent-acp/src/index.ts
Normal file
95
packages/subagent/subagent-acp/src/index.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* The out-of-process ACP subagent backend: registers a {@link SubagentProvider}
|
||||
* on `ctx.subagents` that runs each child agent in a SPAWNED SUBPROCESS, driven
|
||||
* over the Agent Client Protocol (ACP) as the client. The parent process is the
|
||||
* ACP client; the child is any ACP agent (point the configured command at the
|
||||
* `acp-agent` example to "talk to our own process").
|
||||
*
|
||||
* Unlike the in-process backends (`-spawn`/`-fork`), the child does NOT share
|
||||
* this cordis context — it is a separate process with its own session, model
|
||||
* client, and tools. So this backend injects only `subagents` (no `agents`),
|
||||
* advertises NO start-time capabilities (an out-of-process child cannot enforce
|
||||
* the parent's depth/tool-filter), and ignores `request.parent`.
|
||||
*
|
||||
* Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default
|
||||
* export (the cordis Loader's `unwrapExports` does `exports.default ?? exports`,
|
||||
* so a stray default would drop the namespace — see docs/postmortem/0001).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent-acp
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import { type AcpRunSpec, type PermissionPolicy, startAcpRun } from './run.ts'
|
||||
|
||||
export const name = 'subagent-acp'
|
||||
export const inject = ['subagents']
|
||||
|
||||
/** Config: how to spawn and drive the child ACP agent process. */
|
||||
export interface Config {
|
||||
/** Provider name on `ctx.subagents` (default `acp`). */
|
||||
providerName: string
|
||||
/** The executable to spawn for each run (the child ACP agent). */
|
||||
command: string
|
||||
/** Arguments passed to {@link command}. */
|
||||
args: string[]
|
||||
/**
|
||||
* Working directory for the child process and its ACP session. Defaults to
|
||||
* the parent process's cwd when omitted.
|
||||
*/
|
||||
cwd?: string
|
||||
/**
|
||||
* How to auto-answer the child's `session/request_permission` prompts:
|
||||
* `reject` (default — decline every prompt) or `allow` (approve via the first
|
||||
* allow-shaped option). The first cut surfaces no prompt to a human.
|
||||
*/
|
||||
permission: PermissionPolicy
|
||||
/**
|
||||
* Extra environment variables for the child process — e.g. the child
|
||||
* harness's own `DEEPSEEK_API_KEY`. Forwarded on top of a credential-scrubbed
|
||||
* copy of the parent env, so an explicit key here reaches the child while
|
||||
* ambient secrets do not leak implicitly.
|
||||
*/
|
||||
env: Record<string, string>
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
providerName: z.string().default('acp'),
|
||||
command: z.string().required(),
|
||||
args: z.array(z.string()).default([]),
|
||||
cwd: z.string(),
|
||||
permission: z.union(['allow', 'reject'] as const).default('reject'),
|
||||
env: z.dict(z.string()).default({}),
|
||||
})
|
||||
|
||||
/**
|
||||
* The ACP provider. Advertises NO start-time capabilities: an out-of-process
|
||||
* child cannot honor `outputSchema`/`maxDepth`/`toolFilter` (the service rejects
|
||||
* a request needing any of them before `start` runs).
|
||||
*/
|
||||
class AcpProvider implements SubagentProvider {
|
||||
readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false }
|
||||
|
||||
constructor(readonly name: string, private readonly ctx: Context, private readonly config: Config) {}
|
||||
|
||||
start(request: SubagentStartRequest) {
|
||||
const spec: AcpRunSpec = {
|
||||
command: this.config.command,
|
||||
args: this.config.args,
|
||||
cwd: this.config.cwd ?? process.cwd(),
|
||||
permission: this.config.permission,
|
||||
env: this.config.env,
|
||||
onError: (error, stopReason) => {
|
||||
// The seam forbids `result` rejecting, so a child-level failure is
|
||||
// flattened to a stop reason — preserve it here rather than losing it.
|
||||
this.ctx.logger.warn(`subagent-acp "${this.name}": child run failed (${stopReason}): ${error.message}`)
|
||||
},
|
||||
}
|
||||
return startAcpRun(request, spec)
|
||||
}
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
ctx.subagents.registerProvider(new AcpProvider(config.providerName, ctx, config))
|
||||
}
|
||||
401
packages/subagent/subagent-acp/src/run.ts
Normal file
401
packages/subagent/subagent-acp/src/run.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* The out-of-process ACP subagent run driver. Spawns a child agent as a
|
||||
* subprocess, speaks the Agent Client Protocol (ACP) to it over stdio as the
|
||||
* CLIENT, drives one session to completion, and shapes the result into a
|
||||
* {@link SubagentResult}. The mirror image of the server-side bridge in
|
||||
* `@deepseek-ai/dsh-acp` (which is the ACP *agent* side): here we are the ACP
|
||||
* *client*, so we CALL `initialize`/`newSession`/`prompt`/`cancel` and we
|
||||
* IMPLEMENT the `Client` callbacks (`sessionUpdate`, `requestPermission`).
|
||||
*
|
||||
* One subprocess per run (fresh-process-per-run): `start` spawns, runs exactly
|
||||
* one ACP session, and `dispose` kills the subprocess and awaits its exit.
|
||||
* Persistent-process pooling is a future optimization (see the RFC).
|
||||
*
|
||||
* TODO(acp-subagent-replay): snapshot-tier coverage of an ACP child is a
|
||||
* distinct replay shape — each child is its own PROCESS with its own
|
||||
* single-agent replay (the child boots under `DSH_SNAPSHOT=replay` with its own
|
||||
* sessions-root + fixture), unlike the in-process per-session keying in
|
||||
* `dsh-llm-replay`. Deferred to a follow-up; keyless coverage here is via a
|
||||
* scripted mock ACP server subprocess, and the with-key e2e drives the real
|
||||
* `acp-agent` example. See the ACP-subagent-backend RFC.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent-acp/run
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { Readable, Writable } from 'node:stream'
|
||||
import {
|
||||
ClientSideConnection,
|
||||
ndJsonStream,
|
||||
PROTOCOL_VERSION,
|
||||
type Agent as AcpAgent,
|
||||
type Client,
|
||||
type ContentBlock as AcpContentBlock,
|
||||
type RequestPermissionRequest,
|
||||
type RequestPermissionResponse,
|
||||
type SessionNotification,
|
||||
type StopReason,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
|
||||
|
||||
/**
|
||||
* How the client answers a child's `session/request_permission`. The first cut
|
||||
* does not surface permission prompts to a human, so every request is
|
||||
* auto-answered by this fixed policy:
|
||||
*
|
||||
* - `reject` — decline every prompt (answer `cancelled`). Safe default: a child
|
||||
* that asks before a side effect does not get to take it.
|
||||
* - `allow` — approve every prompt by selecting its first `allow_*` option (or,
|
||||
* if none is offered, `cancelled`). Use when the child is trusted to act.
|
||||
*/
|
||||
export type PermissionPolicy = 'allow' | 'reject'
|
||||
|
||||
/** Resolved spawn spec for an ACP child process (no defaults — see Config). */
|
||||
export interface AcpRunSpec {
|
||||
/** The executable to spawn (the child ACP agent). */
|
||||
command: string
|
||||
/** Arguments passed to {@link command}. */
|
||||
args: string[]
|
||||
/** Working directory for the child process AND its ACP session `cwd`. */
|
||||
cwd: string
|
||||
/** How to auto-answer the child's permission prompts. */
|
||||
permission: PermissionPolicy
|
||||
/**
|
||||
* Extra environment variables to ADD for the child (e.g. the child harness's
|
||||
* `DEEPSEEK_API_KEY`). Merged on top of the scrubbed ambient env — see
|
||||
* {@link buildChildEnv}. A value here is forwarded even if its name matches
|
||||
* the credential-scrub pattern (an explicit opt-in for the child's own creds).
|
||||
*/
|
||||
env: Record<string, string>
|
||||
/**
|
||||
* Grace period (ms) for the child's EOF-driven quiesce in
|
||||
* {@link SubagentRun.dispose} — the window to flush persistence and tear down
|
||||
* its OWN nested subprocesses before the parent escalates to a signal. Defaults
|
||||
* to {@link DEFAULT_DISPOSE_EOF_GRACE_MS}; a test injects a small value.
|
||||
*/
|
||||
disposeEofGraceMs?: number
|
||||
/**
|
||||
* Grace period (ms) between `SIGTERM` and the `SIGKILL` escalation in
|
||||
* {@link SubagentRun.dispose}. Defaults to {@link DEFAULT_DISPOSE_GRACE_MS};
|
||||
* a test injects a small value to exercise the escalation without a long wait.
|
||||
*/
|
||||
disposeGraceMs?: number
|
||||
/**
|
||||
* Sink for a child-level failure that the run flattened into a stop reason
|
||||
* (the seam contract forbids `result` rejecting). The driver calls this with
|
||||
* the original error and the chosen stop reason so the fault is preserved
|
||||
* rather than silently lost; the provider wires it to `ctx.logger.warn`.
|
||||
* Optional — omitted in a unit test that asserts the stop reason directly.
|
||||
*/
|
||||
onError?: (error: Error, stopReason: SubagentStopReason) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Default grace for the child's EOF-driven quiesce on dispose — the window for it
|
||||
* to flush persistence and tear down its OWN nested subprocesses (which may run
|
||||
* their own `SIGTERM`→`SIGKILL` escalation) before the parent escalates to a
|
||||
* signal. Deliberately LARGER than {@link DEFAULT_DISPOSE_GRACE_MS}: a cooperative
|
||||
* child whose teardown is itself waiting on a signal-trapping grandchild (e.g. a
|
||||
* bash subprocess in its own ~3s SIGTERM→SIGKILL grace) plus a final flush needs
|
||||
* MORE than a single signal-grace of headroom, or the parent's SIGTERM cuts it off
|
||||
* exactly as it reaches its own SIGKILL+flush. The child is an arbitrary ACP agent,
|
||||
* so this is a standalone generous default, NOT derived from any child's internals.
|
||||
*/
|
||||
export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000
|
||||
|
||||
/** Default grace between SIGTERM and SIGKILL on dispose (mirrors the bash executor). */
|
||||
export const DEFAULT_DISPOSE_GRACE_MS = 3_000
|
||||
|
||||
/**
|
||||
* Credential-shaped ambient env vars are NOT forwarded to the child by default
|
||||
* (the parent harness's own `DEEPSEEK_API_KEY`/secrets must not leak into a
|
||||
* spawned process implicitly). Same pattern as the bash executor. The child
|
||||
* agent needs its OWN credentials to reach a model — those are supplied
|
||||
* explicitly via {@link AcpRunSpec.env}, which is layered on top AFTER the
|
||||
* scrub, so an intended `DEEPSEEK_API_KEY` survives while an incidental
|
||||
* `AWS_SECRET_ACCESS_KEY` does not.
|
||||
*/
|
||||
export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
|
||||
|
||||
/** The ambient env minus credential-shaped vars, plus the spec's explicit env. */
|
||||
export function buildChildEnv(extra: Record<string, string>): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (!SENSITIVE_ENV_PATTERN.test(key)) env[key] = value
|
||||
}
|
||||
return { ...env, ...extra }
|
||||
}
|
||||
|
||||
/** Map an ACP {@link StopReason} to a harness {@link SubagentStopReason}. */
|
||||
export function acpStopReason(reason: StopReason): SubagentStopReason {
|
||||
switch (reason) {
|
||||
case 'end_turn':
|
||||
return 'completed'
|
||||
case 'max_tokens':
|
||||
return 'max-tokens'
|
||||
case 'refusal':
|
||||
return 'refusal'
|
||||
case 'cancelled':
|
||||
return 'aborted'
|
||||
// `max_turn_requests` (the child hit its turn-request budget) has no direct
|
||||
// harness equivalent and means the task did NOT finish cleanly — surface it
|
||||
// as a generic failure so the consumer maps it to an isError result rather
|
||||
// than reporting a partial answer as success.
|
||||
case 'max_turn_requests':
|
||||
return 'error'
|
||||
// ACP StopReason is a closed wire union, but a future SDK could add a
|
||||
// variant; treat an unknown terminal reason as a failure (never silently
|
||||
// 'completed').
|
||||
default:
|
||||
return 'error'
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect the text of an ACP content block (non-text blocks contribute nothing). */
|
||||
export function acpContentText(content: AcpContentBlock): string {
|
||||
return content.type === 'text' ? content.text : ''
|
||||
}
|
||||
|
||||
/** Translate the harness prompt blocks into ACP prompt blocks (text only). */
|
||||
export function toAcpPrompt(prompt: ContentBlock[]): AcpContentBlock[] {
|
||||
const blocks: AcpContentBlock[] = []
|
||||
for (const block of prompt) {
|
||||
if (block.type === 'text') blocks.push({ type: 'text', text: block.text })
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
/** Normalize an unknown thrown value to an Error (the catch binding is `unknown`). */
|
||||
function toError(value: unknown): Error {
|
||||
// The catch only sees rejections from the ACP SDK RPCs and the spawn `error`
|
||||
// event, which are always `Error`s; the `String(value)` arm is a defensive
|
||||
// fallback for a non-Error throw that the typed surfaces cannot produce.
|
||||
/* v8 ignore next */
|
||||
return value instanceof Error ? value : new Error(String(value))
|
||||
}
|
||||
|
||||
/** Resolve once the child process exits (any code/signal); immediate if gone. */
|
||||
function waitForExit(child: ChildProcess): Promise<void> {
|
||||
// Already-exited fast path: dispose guards on exitCode before calling, so in
|
||||
// tests the child is always still alive here.
|
||||
/* v8 ignore next */
|
||||
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
|
||||
return new Promise<void>(resolve => child.once('exit', () => { resolve() }))
|
||||
}
|
||||
|
||||
/** Resolve `true` if the child exits within `ms`, `false` on timeout. */
|
||||
function exitsWithin(child: ChildProcess, ms: number): Promise<boolean> {
|
||||
return Promise.race([
|
||||
waitForExit(child).then(() => true),
|
||||
// `.unref()` so a pending grace timer never keeps the parent's loop alive.
|
||||
new Promise<boolean>(resolve => setTimeout(() => { resolve(false) }, ms).unref()),
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an out-of-process ACP child for `request` and return a {@link SubagentRun}.
|
||||
*
|
||||
* Spawns the configured command, wraps its stdio in an ACP `ClientSideConnection`,
|
||||
* and drives one session: `initialize` → `newSession` → `prompt`. The accumulated
|
||||
* `agent_message_chunk` text is the result output; the prompt's terminal
|
||||
* `StopReason` maps to the stop reason. `result` never REJECTS on a child-level
|
||||
* failure (a spawn/transport/RPC error resolves with `stopReason: 'error'`), per
|
||||
* the seam contract. `cancel()` sends `session/cancel`; `dispose()` kills the
|
||||
* subprocess and awaits its exit (quiescent teardown).
|
||||
*/
|
||||
export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): SubagentRun {
|
||||
const id = AgentId(randomUUID())
|
||||
|
||||
// A request already aborted before it starts never spawns the child at all —
|
||||
// return an inert run that settled `aborted`, rather than launching the
|
||||
// configured binary just to tear it down. `dispose`/`cancel` are no-ops.
|
||||
if (request.signal?.aborted) {
|
||||
return {
|
||||
id,
|
||||
result: Promise.resolve({ output: [], stopReason: 'aborted' }),
|
||||
cancel(_reason?: string): void { /* nothing was started */ },
|
||||
dispose(): Promise<void> { return Promise.resolve() },
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn the child ACP agent. stdin = ACP request channel, stdout = ACP
|
||||
// response channel, stderr = INHERIT so the child's diagnostics surface on the
|
||||
// parent's stderr (no separate capture to drain — we don't fold child stderr
|
||||
// into the result; the seam reports only output + stop reason).
|
||||
const child = spawn(spec.command, spec.args, {
|
||||
cwd: spec.cwd,
|
||||
env: buildChildEnv(spec.env),
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
})
|
||||
// A spawn-level failure (e.g. ENOENT for a bad command) is emitted as an
|
||||
// `error` event, NOT a thrown exception — without a listener Node treats it as
|
||||
// an unhandled error and crashes the parent. Capture it into a promise the
|
||||
// result path races, so a bad command settles `error` like any child failure.
|
||||
const spawnFailed = new Promise<Error>((resolve) => {
|
||||
child.once('error', (err) => { resolve(err) })
|
||||
})
|
||||
|
||||
// Accumulate the child's streamed assistant text — the SubagentResult output.
|
||||
const output: string[] = []
|
||||
// `cancelled` records that a cancel was requested (signal or cancel()), so a
|
||||
// run torn down before the prompt resolves settles `aborted` rather than the
|
||||
// generic error mapping. Held on a mutable object so the async closures that
|
||||
// set it (the abort listener) and the IIFE that reads it don't fight TS's
|
||||
// control-flow narrowing of a bare `let` (which would type the catch-time read
|
||||
// as always-`false`).
|
||||
const flags = { cancelled: false }
|
||||
|
||||
const makeClient = (_agent: AcpAgent): Client => ({
|
||||
sessionUpdate(params: SessionNotification): Promise<void> {
|
||||
const update = params.update
|
||||
if (update.sessionUpdate === 'agent_message_chunk') {
|
||||
output.push(acpContentText(update.content))
|
||||
}
|
||||
// Other updates (thoughts, tool calls, plans) are consumed but not
|
||||
// surfaced in this cut — the subagent returns only its final answer.
|
||||
return Promise.resolve()
|
||||
},
|
||||
requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
||||
// Auto-answer by the configured policy. `allow` selects the first
|
||||
// allow-shaped option the child offered; if it offered none (or we
|
||||
// reject), answer `cancelled` so the child does not proceed.
|
||||
if (spec.permission === 'allow') {
|
||||
const allow = params.options.find(o => o.kind === 'allow_once' || o.kind === 'allow_always')
|
||||
if (allow !== undefined) {
|
||||
return Promise.resolve({ outcome: { outcome: 'selected', optionId: allow.optionId } })
|
||||
}
|
||||
}
|
||||
return Promise.resolve({ outcome: { outcome: 'cancelled' } })
|
||||
},
|
||||
})
|
||||
|
||||
const conn = new ClientSideConnection(
|
||||
makeClient,
|
||||
ndJsonStream(
|
||||
Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
|
||||
Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>,
|
||||
),
|
||||
)
|
||||
|
||||
let sessionId: string | undefined
|
||||
// Resolves when a cancel is requested, so `result` can settle `aborted` even
|
||||
// if the child never cooperates with `session/cancel` (it ignores the notify,
|
||||
// or the prompt wedges). The result path races this against the ACP drive: the
|
||||
// FIRST to settle wins, so `cancel()` always honors the contract (`result`
|
||||
// settles `aborted`) without waiting on a non-cooperative child. `dispose`
|
||||
// still kills the process and reaps it; this only unblocks `result`. The
|
||||
// executor runs synchronously, so `signalCancelSettled` is assigned before the
|
||||
// Promise constructor returns (the `!` asserts the definite assignment).
|
||||
let signalCancelSettled!: () => void
|
||||
const cancelSettled = new Promise<void>((resolve) => { signalCancelSettled = resolve })
|
||||
const requestCancel = (): void => {
|
||||
flags.cancelled = true
|
||||
signalCancelSettled()
|
||||
// Best-effort: tell the child to cancel the in-flight turn. Swallows a
|
||||
// rejection — the session may not exist yet, or the pipe may be gone; the
|
||||
// dispose path kills the process regardless. If the session has NOT been
|
||||
// created yet (cancel raced ahead of `newSession`), the `cancelled` flag
|
||||
// alone carries it: the result path re-checks the flag after each await and
|
||||
// settles `aborted` without running the prompt. The `.catch` swallow is
|
||||
// defensive for a narrow transport race (child gone mid-send) — v8-ignored
|
||||
// because dispose kills the process regardless, so it can't be hit in tests.
|
||||
/* v8 ignore next */
|
||||
if (sessionId !== undefined) void conn.cancel({ sessionId }).catch(() => { /* child gone / no session */ })
|
||||
}
|
||||
const onAbort = (): void => { requestCancel() }
|
||||
request.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
const result: Promise<SubagentResult> = (async (): Promise<SubagentResult> => {
|
||||
// The accumulated child text as harness ContentBlocks (empty array when the
|
||||
// child streamed nothing). Read at every return so a partial answer survives
|
||||
// a later cancel/error.
|
||||
const collectOutput = (): ContentBlock[] => {
|
||||
const text = output.join('')
|
||||
return text.length > 0 ? [{ type: 'text', text }] : []
|
||||
}
|
||||
try {
|
||||
// Race three outcomes, first to settle wins:
|
||||
// - driveAcp: the normal initialize → newSession → prompt path;
|
||||
// - spawnFailed: a bad command never speaks ACP, so `initialize` would
|
||||
// hang forever — the spawn `error` event is the only signal, and a
|
||||
// rejected race settles the run `error` via the catch;
|
||||
// - cancelSettled: a cancel was requested — settle `aborted` immediately
|
||||
// rather than waiting on a child that may ignore `session/cancel` or
|
||||
// wedge the prompt (the `cancel()` contract: `result` settles `aborted`).
|
||||
const driveAcp = async (): Promise<SubagentResult> => {
|
||||
await conn.initialize({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
// Advertise NO optional client capabilities (no fs, no terminal): the
|
||||
// child self-serves in its own process.
|
||||
clientCapabilities: {},
|
||||
})
|
||||
const session = await conn.newSession({ cwd: spec.cwd, mcpServers: [] })
|
||||
sessionId = session.sessionId
|
||||
// A cancel that raced ahead of `newSession` set `cancelled` but could not
|
||||
// send `session/cancel` (no session id yet). Honor it here: settle
|
||||
// `aborted` without ever issuing the prompt, rather than running the child
|
||||
// to completion and ignoring the cancel.
|
||||
if (flags.cancelled) return { output: collectOutput(), stopReason: 'aborted' }
|
||||
const promptResult = await conn.prompt({ sessionId, prompt: toAcpPrompt(request.prompt) })
|
||||
return { output: collectOutput(), stopReason: acpStopReason(promptResult.stopReason) }
|
||||
}
|
||||
return await Promise.race([
|
||||
driveAcp(),
|
||||
spawnFailed.then((err): SubagentResult => { throw err }),
|
||||
cancelSettled.then((): SubagentResult => ({ output: collectOutput(), stopReason: 'aborted' })),
|
||||
])
|
||||
} catch (error: unknown) {
|
||||
// The seam contract: result resolves (never rejects) on a child-level
|
||||
// failure. Cancellation is handled by the `cancelSettled` race arm above
|
||||
// (it settles `aborted` the instant cancel is requested, beating any
|
||||
// rejection), so a rejection that reaches HERE is always a genuine
|
||||
// child-level error — the awaited ACP RPCs or the spawn-failure race
|
||||
// (initialize/newSession/prompt transport/RPC errors, or ENOENT), not a
|
||||
// local bug. Flatten to `error` and surface the original via onError so a
|
||||
// real fault is preserved rather than silently lost.
|
||||
spec.onError?.(toError(error), 'error')
|
||||
return { output: collectOutput(), stopReason: 'error' }
|
||||
}
|
||||
})()
|
||||
|
||||
return {
|
||||
id,
|
||||
result,
|
||||
cancel(_reason?: string): void {
|
||||
requestCancel()
|
||||
},
|
||||
async dispose(): Promise<void> {
|
||||
request.signal?.removeEventListener('abort', onAbort)
|
||||
// Reach quiescence, not merely request it (dispose must AWAIT the child
|
||||
// actually stopping). If the child is already gone, nothing to do.
|
||||
if (child.exitCode !== null || child.signalCode !== null) return
|
||||
const eofGraceMs = spec.disposeEofGraceMs ?? DEFAULT_DISPOSE_EOF_GRACE_MS
|
||||
const graceMs = spec.disposeGraceMs ?? DEFAULT_DISPOSE_GRACE_MS
|
||||
// 1. Graceful: end the ACP request stream (stdin EOF) and let the child
|
||||
// quiesce ON ITS OWN. Our acp-agent has NO SIGTERM handler in a normal
|
||||
// session — it tears down via the server bridge's connection-close path
|
||||
// (conn.closed → per-agent dispose → final session/flush), driven by the
|
||||
// stdin EOF, NOT by a signal. A prompt response can resolve from a
|
||||
// turn/end BEFORE that post-turn flush lands, so the child still has
|
||||
// durable work owed when dispose runs. Give the EOF-driven quiesce a real
|
||||
// window — wider than a single signal-grace, since the child's own
|
||||
// teardown may itself be awaiting a signal-trapping grandchild (a bash
|
||||
// subprocess in its own SIGTERM→SIGKILL grace) plus a flush — and only
|
||||
// escalate if it overruns. Sending SIGTERM in the same tick (or too soon)
|
||||
// would default-terminate the child mid-flush, orphaning its nested work.
|
||||
child.stdin.end()
|
||||
if (await exitsWithin(child, eofGraceMs)) return
|
||||
// 2. SIGTERM, then escalate to SIGKILL if it still does not exit within the
|
||||
// grace period — a child that ignores EOF and traps SIGTERM must not
|
||||
// wedge dispose forever (the seam requires bounded quiescence).
|
||||
child.kill('SIGTERM')
|
||||
if (await exitsWithin(child, graceMs)) return
|
||||
// 3. Force-kill and await the (now-certain) exit.
|
||||
child.kill('SIGKILL')
|
||||
await waitForExit(child)
|
||||
},
|
||||
}
|
||||
}
|
||||
228
packages/subagent/subagent-acp/tests/mock-acp-server.ts
Normal file
228
packages/subagent/subagent-acp/tests/mock-acp-server.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* A minimal mock ACP AGENT, run as a subprocess, for the keyless
|
||||
* `dsh-subagent-acp` tests. It speaks the agent side of ACP over stdio and is
|
||||
* fully scripted by environment variables — no model, no network:
|
||||
*
|
||||
* - `MOCK_TEXT` — the assistant text it streams as one `agent_message_chunk`.
|
||||
* - `MOCK_STOP` — the ACP `StopReason` it returns from `prompt`
|
||||
* (`end_turn` default, or `max_tokens`/`refusal`/…).
|
||||
* - `MOCK_HANG` — if `1`, `prompt` never resolves on its own (it waits for
|
||||
* a `session/cancel`), to exercise the client's cancel path.
|
||||
* - `MOCK_IGNORE_CANCEL` — if `1` (with MOCK_HANG), the agent receives
|
||||
* `session/cancel` but NEVER resolves the pending prompt
|
||||
* and never exits — a non-cooperative child. The backend's
|
||||
* `result` must still settle `aborted` on its own and
|
||||
* `dispose()` must still kill the process.
|
||||
* - `MOCK_PERMISSION` — if `1`, the agent calls `session/request_permission`
|
||||
* before answering, to exercise the client's auto-answer.
|
||||
* - `MOCK_READY_FILE` — if set, the path the agent touches once its `prompt`
|
||||
* handler is in flight (it has streamed its chunk). A test
|
||||
* polls for this file to cancel on a CONDITION rather than
|
||||
* an arbitrary timeout (subprocess cold-start is variable).
|
||||
* - `MOCK_FLUSH_ON_EOF` — if set, on stdin EOF the agent takes an async beat
|
||||
* (MOCK_FLUSH_DELAY_MS, default 150) simulating the real
|
||||
* acp-agent's EOF-driven quiesce+flush, then touches this
|
||||
* path and exits ON ITS OWN — no signal. Stands in for a
|
||||
* child whose durable flush completes only if dispose
|
||||
* gives EOF a real window before escalating to SIGTERM.
|
||||
* - `MOCK_IGNORE_EOF` — if `1`, keep the event loop alive past stdin EOF (a bare
|
||||
* timer) but install a SIGTERM handler that exits (and, if
|
||||
* MOCK_SIGTERM_FILE is set, touches it as an observable
|
||||
* proof the SIGTERM rung fired). The child ignores the
|
||||
* graceful EOF window yet dies cooperatively on SIGTERM —
|
||||
* exercising dispose's middle tier (exit during the SIGTERM
|
||||
* grace, before the SIGKILL escalation). Touches
|
||||
* MOCK_READY_FILE once armed.
|
||||
*
|
||||
* It is NOT a test spec (no `describe`/`it`) — it is spawned BY the specs as the
|
||||
* child process the ACP backend drives. Kept as a `.ts` run under tsx by the
|
||||
* spec (which passes its own tsconfig), mirroring how the snapshot harness boots
|
||||
* the real example.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent-acp/tests/mock-acp-server
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { existsSync, writeFileSync } from 'node:fs'
|
||||
import { Readable, Writable } from 'node:stream'
|
||||
import {
|
||||
AgentSideConnection,
|
||||
ndJsonStream,
|
||||
PROTOCOL_VERSION,
|
||||
type Agent,
|
||||
type CancelNotification,
|
||||
type AuthenticateRequest,
|
||||
type InitializeRequest,
|
||||
type InitializeResponse,
|
||||
type NewSessionRequest,
|
||||
type NewSessionResponse,
|
||||
type PromptRequest,
|
||||
type PromptResponse,
|
||||
type StopReason,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
|
||||
const TEXT = process.env.MOCK_TEXT ?? 'mock child answer'
|
||||
const STOP = (process.env.MOCK_STOP ?? 'end_turn') as StopReason
|
||||
const HANG = process.env.MOCK_HANG === '1'
|
||||
const WANT_PERMISSION = process.env.MOCK_PERMISSION === '1'
|
||||
const NO_ALLOW = process.env.MOCK_NO_ALLOW === '1'
|
||||
const THOUGHT = process.env.MOCK_THOUGHT === '1'
|
||||
const CRASH_ON_CANCEL = process.env.MOCK_CRASH_ON_CANCEL === '1'
|
||||
const IGNORE_CANCEL = process.env.MOCK_IGNORE_CANCEL === '1'
|
||||
const READY_FILE = process.env.MOCK_READY_FILE
|
||||
const FLUSH_ON_EOF = process.env.MOCK_FLUSH_ON_EOF
|
||||
// When MOCK_NEWSESSION_READY/GO are set, newSession touches READY then blocks
|
||||
// until GO appears — letting a test cancel mid-newSession deterministically.
|
||||
const NEWSESSION_GATE = process.env.MOCK_NEWSESSION_READY !== undefined && process.env.MOCK_NEWSESSION_GO !== undefined
|
||||
? { ready: process.env.MOCK_NEWSESSION_READY, go: process.env.MOCK_NEWSESSION_GO }
|
||||
: undefined
|
||||
|
||||
function makeAgent(conn: AgentSideConnection): Agent {
|
||||
// Pending cancel resolver for the HANG path: a `session/cancel` resolves the
|
||||
// prompt with `cancelled`.
|
||||
let resolveCancel: ((reason: StopReason) => void) | undefined
|
||||
|
||||
return {
|
||||
initialize(_params: InitializeRequest): Promise<InitializeResponse> {
|
||||
return Promise.resolve({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
agentCapabilities: { loadSession: false, promptCapabilities: { image: false, audio: false, embeddedContext: false } },
|
||||
authMethods: [],
|
||||
})
|
||||
},
|
||||
async newSession(_params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
// Optionally signal "newSession reached" and block until released, so a
|
||||
// test can cancel DURING newSession (the early-cancel race window) on a
|
||||
// condition rather than a timeout.
|
||||
if (NEWSESSION_GATE !== undefined) {
|
||||
writeFileSync(NEWSESSION_GATE.ready, 'at-newSession')
|
||||
while (!existsSync(NEWSESSION_GATE.go)) await new Promise(r => setTimeout(r, 10))
|
||||
}
|
||||
return { sessionId: randomUUID() }
|
||||
},
|
||||
authenticate(_params: AuthenticateRequest): Promise<void> {
|
||||
// No auth methods advertised; nothing to do.
|
||||
return Promise.resolve()
|
||||
},
|
||||
async prompt(params: PromptRequest): Promise<PromptResponse> {
|
||||
if (WANT_PERMISSION) {
|
||||
// Ask the client to approve before answering; honor its decision. Under
|
||||
// MOCK_NO_ALLOW the only options are reject-shaped, so an `allow`-policy
|
||||
// client finds no allow option and must fall back to cancelled.
|
||||
const options = NO_ALLOW
|
||||
? [{ optionId: 'no', name: 'Reject', kind: 'reject_once' as const }]
|
||||
: [
|
||||
{ optionId: 'yes', name: 'Allow', kind: 'allow_once' as const },
|
||||
{ optionId: 'no', name: 'Reject', kind: 'reject_once' as const },
|
||||
]
|
||||
const decision = await conn.requestPermission({
|
||||
sessionId: params.sessionId,
|
||||
toolCall: { toolCallId: 'mock-call', title: 'mock side effect' },
|
||||
options,
|
||||
})
|
||||
if (decision.outcome.outcome === 'cancelled') {
|
||||
return { stopReason: 'cancelled' }
|
||||
}
|
||||
}
|
||||
// Optionally emit a NON-message update first (a thought), so the client's
|
||||
// sessionUpdate sees an update it must consume-but-not-accumulate.
|
||||
if (THOUGHT) {
|
||||
await conn.sessionUpdate({
|
||||
sessionId: params.sessionId,
|
||||
update: { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'thinking…' } },
|
||||
})
|
||||
}
|
||||
// Stream the canned assistant text as one chunk.
|
||||
await conn.sessionUpdate({
|
||||
sessionId: params.sessionId,
|
||||
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: TEXT } },
|
||||
})
|
||||
// Signal "prompt is in flight" by touching the readiness file, so a test
|
||||
// can wait on a CONDITION (file exists) rather than an arbitrary timeout
|
||||
// before cancelling — deterministic regardless of subprocess cold-start.
|
||||
if (READY_FILE !== undefined) writeFileSync(READY_FILE, 'ready')
|
||||
if (HANG) {
|
||||
// Never resolve on our own: wait for session/cancel to settle us.
|
||||
return new Promise<PromptResponse>((resolve) => {
|
||||
resolveCancel = (reason) => { resolve({ stopReason: reason }) }
|
||||
})
|
||||
}
|
||||
return { stopReason: STOP }
|
||||
},
|
||||
cancel(_params: CancelNotification): Promise<void> {
|
||||
if (CRASH_ON_CANCEL) {
|
||||
// Exit hard instead of answering — tears the ACP pipe, so the client's
|
||||
// pending prompt REJECTS (exercises the backend's catch-while-cancelled
|
||||
// path: a transport failure after a cancel settles `aborted`).
|
||||
process.exit(1)
|
||||
}
|
||||
if (IGNORE_CANCEL) {
|
||||
// A NON-COOPERATIVE child: receive session/cancel but never resolve the
|
||||
// pending prompt and never exit. The backend's `result` must still settle
|
||||
// `aborted` on its own (the cancel-settle race), and `dispose()` must
|
||||
// still kill the process — proving cancellation does not depend on the
|
||||
// child cooperating.
|
||||
return Promise.resolve()
|
||||
}
|
||||
resolveCancel?.('cancelled')
|
||||
return Promise.resolve()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
new AgentSideConnection(
|
||||
makeAgent,
|
||||
ndJsonStream(
|
||||
Writable.toWeb(process.stdout) as WritableStream<Uint8Array>,
|
||||
Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>,
|
||||
),
|
||||
)
|
||||
|
||||
// Under MOCK_TRAP_SIGTERM, ignore SIGTERM and keep stdin open so the process
|
||||
// neither quiesces on EOF nor dies on the graceful signal — exercising the
|
||||
// backend dispose path's SIGKILL escalation. Without this the process exits
|
||||
// normally on SIGTERM / stdin end. Touch READY_FILE once the trap is armed, so
|
||||
// a test waits for that CONDITION before disposing (the trap must be in place,
|
||||
// not merely the process spawned — otherwise SIGTERM hits the default handler).
|
||||
if (process.env.MOCK_TRAP_SIGTERM === '1') {
|
||||
process.on('SIGTERM', () => { /* trapped: refuse to exit on the graceful signal */ })
|
||||
// Keep the event loop alive (a bare timer) so nothing else lets it exit.
|
||||
setInterval(() => { /* stay alive until SIGKILL */ }, 1000)
|
||||
if (READY_FILE !== undefined) writeFileSync(READY_FILE, 'trap-armed')
|
||||
}
|
||||
|
||||
// Under MOCK_FLUSH_ON_EOF, model the real acp-agent's EOF-driven quiesce: on
|
||||
// stdin 'end' (the dispose path's `child.stdin.end()`), take an ASYNC beat to
|
||||
// "flush", then touch the marker and exit ON OUR OWN — no signal involved. The
|
||||
// beat is MOCK_FLUSH_DELAY_MS (default 150). A dispose that sends SIGTERM before
|
||||
// the beat completes (no graceful window, or an EOF grace shorter than the
|
||||
// flush) default-terminates this process and the marker is missing; a dispose
|
||||
// that gives the EOF quiesce enough window first lets the flush land.
|
||||
if (FLUSH_ON_EOF !== undefined) {
|
||||
const flushDelayMs = Number(process.env.MOCK_FLUSH_DELAY_MS ?? '150')
|
||||
process.stdin.on('end', () => {
|
||||
setTimeout(() => {
|
||||
writeFileSync(FLUSH_ON_EOF, 'flushed')
|
||||
process.exit(0)
|
||||
}, flushDelayMs)
|
||||
})
|
||||
}
|
||||
|
||||
// Under MOCK_IGNORE_EOF, keep the loop alive past stdin EOF (so the graceful EOF
|
||||
// window times out) but INSTALL A SIGTERM HANDLER that records it and exits — the
|
||||
// child ignores the graceful EOF window yet dies cooperatively on SIGTERM,
|
||||
// exercising dispose's MIDDLE tier (exit during the SIGTERM grace, before the
|
||||
// SIGKILL escalation). When MOCK_SIGTERM_FILE is set the handler touches it, an
|
||||
// OBSERVABLE proof that the SIGTERM rung fired: if dispose skipped the middle
|
||||
// rung and jumped EOF→SIGKILL, SIGKILL is uncatchable so the handler never runs
|
||||
// and the marker is missing. Touch READY_FILE once armed (a test waits on it).
|
||||
if (process.env.MOCK_IGNORE_EOF === '1') {
|
||||
const sigtermFile = process.env.MOCK_SIGTERM_FILE
|
||||
process.on('SIGTERM', () => {
|
||||
if (sigtermFile !== undefined) writeFileSync(sigtermFile, 'sigterm')
|
||||
process.exit(0)
|
||||
})
|
||||
setInterval(() => { /* stay alive past EOF until SIGTERM */ }, 1000)
|
||||
if (READY_FILE !== undefined) writeFileSync(READY_FILE, 'ignore-eof-armed')
|
||||
}
|
||||
|
||||
110
packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts
Normal file
110
packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import * as acp from '../src/index.ts'
|
||||
|
||||
/**
|
||||
* With-key e2e for the ACP subagent backend: the harness drives ITSELF as an ACP
|
||||
* server. The backend spawns the real `acp-agent` example as a child PROCESS,
|
||||
* speaks ACP to it over stdio, and the child runs the REAL model in its own
|
||||
* process to answer a prompt. We verify the child's real answer comes back
|
||||
* through the seam — the "talk to our own process" smoke the design called for.
|
||||
* Key-gated (self-skips without DEEPSEEK_API_KEY).
|
||||
*
|
||||
* This is the out-of-process analogue of the in-process spawn e2e: there a
|
||||
* parent agent on the same context drove a child; here the child is a separate
|
||||
* process reached over ACP, proving the seam generalizes across the boundary.
|
||||
*/
|
||||
|
||||
// The real acp-agent example: its bin + cordis.yml (the live DeepSeek config).
|
||||
const binScript = fileURLToPath(new URL('../../../ui/acp-agent/src/bin.ts', import.meta.url))
|
||||
const exampleConfig = fileURLToPath(new URL('../../../../examples/acp-agent/cordis.yml', import.meta.url))
|
||||
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
|
||||
/** The ACP backend ignores the parent, but the seam requires one. */
|
||||
const fakeParent = { id: 'parent', session: { header: {} } } as unknown as Agent
|
||||
|
||||
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)('ACP backend with-key e2e (drive our own acp-agent)', () => {
|
||||
it('drives the real acp-agent example process to answer a prompt', async () => {
|
||||
workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-acp-e2e-'))
|
||||
ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(acp, {
|
||||
providerName: 'acp',
|
||||
command: process.execPath,
|
||||
args: ['--import', tsxLoader, binScript, exampleConfig],
|
||||
cwd: workdir,
|
||||
permission: 'reject',
|
||||
// The child harness needs the key to reach the model; forward it
|
||||
// explicitly (buildChildEnv scrubs ambient creds but keeps these extras).
|
||||
env: {
|
||||
...process.env.DEEPSEEK_API_KEY !== undefined ? { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY } : {},
|
||||
...process.env.DEEPSEEK_BASE_URL !== undefined ? { DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL } : {},
|
||||
TSX_TSCONFIG_PATH: repoTsconfig,
|
||||
},
|
||||
})
|
||||
|
||||
const run = ctx.subagents.start('acp', {
|
||||
prompt: [{ type: 'text', text: 'Reply with exactly the word PONG and nothing else. Do not use any tools.' }],
|
||||
parent: fakeParent,
|
||||
})
|
||||
const result = await run.result
|
||||
await run.dispose()
|
||||
|
||||
// The real child process completed its turn and streamed a real answer back
|
||||
// across the ACP boundary.
|
||||
expect(result.stopReason).toBe('completed')
|
||||
const text = result.output.filter(b => b.type === 'text').map(b => (b as { text: string }).text).join('')
|
||||
expect(text.length).toBeGreaterThan(0)
|
||||
expect(text.toUpperCase()).toContain('PONG')
|
||||
}, 180_000)
|
||||
|
||||
it('drives the child to do real file work via its own bash tool', async () => {
|
||||
workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-acp-e2e-'))
|
||||
ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(acp, {
|
||||
providerName: 'acp',
|
||||
command: process.execPath,
|
||||
args: ['--import', tsxLoader, binScript, exampleConfig],
|
||||
cwd: workdir,
|
||||
// The child needs to act (run bash), so approve its permission prompts.
|
||||
permission: 'allow',
|
||||
env: {
|
||||
...process.env.DEEPSEEK_API_KEY !== undefined ? { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY } : {},
|
||||
...process.env.DEEPSEEK_BASE_URL !== undefined ? { DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL } : {},
|
||||
TSX_TSCONFIG_PATH: repoTsconfig,
|
||||
},
|
||||
})
|
||||
|
||||
const run = ctx.subagents.start('acp', {
|
||||
prompt: [{ type: 'text', text:
|
||||
'Use the bash tool to write the text ACP_CHILD_WAS_HERE into a file named proof.txt '
|
||||
+ 'in the current directory. Then reply DONE.' }],
|
||||
parent: fakeParent,
|
||||
})
|
||||
const result = await run.result
|
||||
await run.dispose()
|
||||
|
||||
expect(result.stopReason).toBe('completed')
|
||||
// Verify the WORLD: the child process actually wrote the file in its cwd.
|
||||
const proof = await readFile(join(workdir, 'proof.txt'), 'utf8')
|
||||
expect(proof).toContain('ACP_CHILD_WAS_HERE')
|
||||
}, 180_000)
|
||||
})
|
||||
512
packages/subagent/subagent-acp/tests/subagent-acp.spec.ts
Normal file
512
packages/subagent/subagent-acp/tests/subagent-acp.spec.ts
Normal file
@@ -0,0 +1,512 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import * as acp from '../src/index.ts'
|
||||
import { acpStopReason, acpContentText, buildChildEnv, SENSITIVE_ENV_PATTERN, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts'
|
||||
|
||||
/**
|
||||
* Keyless integration tests for the ACP subagent backend. Each spawns a REAL
|
||||
* subprocess — the scripted mock ACP server (tests/mock-acp-server.ts) — and
|
||||
* drives it through the REAL backend over real ACP JSON-RPC stdio, so the
|
||||
* connection setup, the client callbacks, the prompt round-trip, the stop-reason
|
||||
* mapping, cancellation, and quiescent disposal are all exercised end to end.
|
||||
* No model, no key.
|
||||
*/
|
||||
|
||||
const mockServer = fileURLToPath(new URL('./mock-acp-server.ts', import.meta.url))
|
||||
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
|
||||
/** A throwaway parent Agent — the ACP backend ignores it, but the seam requires one. */
|
||||
const fakeParent = { id: 'parent', session: { header: {} } } as unknown as Agent
|
||||
|
||||
interface SetupEnv {
|
||||
/** Mock-server scripting env: MOCK_TEXT / MOCK_STOP / MOCK_HANG / MOCK_PERMISSION. */
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the ACP backend pointed at the mock server, scripted by `mockEnv`.
|
||||
* `permission` selects the backend's auto-answer policy.
|
||||
*/
|
||||
async function setup(mockEnv: SetupEnv = {}, permission: 'allow' | 'reject' = 'reject') {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(acp, {
|
||||
providerName: 'acp',
|
||||
command: process.execPath,
|
||||
args: ['--import', tsxLoader, mockServer],
|
||||
permission,
|
||||
// The mock-server scripting vars must reach the child; TSX_TSCONFIG_PATH lets
|
||||
// tsx resolve @deepseek-ai/* from a child cwd outside the repo.
|
||||
env: { ...mockEnv, TSX_TSCONFIG_PATH: repoTsconfig },
|
||||
})
|
||||
return ctx
|
||||
}
|
||||
|
||||
function text(blocks: { type: string; text?: string }[]): string {
|
||||
return blocks.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll until `file` exists (the mock touches it once its prompt is in flight),
|
||||
* so a cancel test waits on a CONDITION rather than an arbitrary timeout — the
|
||||
* subprocess cold-start under tsx is variable, and a fixed sleep both flakes and
|
||||
* slows the suite. Fails loud if the child never signals readiness.
|
||||
*/
|
||||
async function waitForFile(file: string, timeoutMs = 5000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (!existsSync(file)) {
|
||||
if (Date.now() > deadline) throw new Error(`mock child never became ready (${file})`)
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
}
|
||||
}
|
||||
|
||||
describe('acpStopReason', () => {
|
||||
it('maps each ACP stop reason to the harness vocabulary', () => {
|
||||
expect(acpStopReason('end_turn')).toBe('completed')
|
||||
expect(acpStopReason('max_tokens')).toBe('max-tokens')
|
||||
expect(acpStopReason('refusal')).toBe('refusal')
|
||||
expect(acpStopReason('cancelled')).toBe('aborted')
|
||||
expect(acpStopReason('max_turn_requests')).toBe('error')
|
||||
})
|
||||
|
||||
it('treats an unknown terminal reason as an error', () => {
|
||||
expect(acpStopReason('something-new' as never)).toBe('error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('acpContentText / toAcpPrompt', () => {
|
||||
it('extracts text from a text content block, empty for non-text', () => {
|
||||
expect(acpContentText({ type: 'text', text: 'hi' })).toBe('hi')
|
||||
// A non-text ACP content block (e.g. an image) contributes no text.
|
||||
expect(acpContentText({ type: 'image', data: 'x', mimeType: 'image/png' })).toBe('')
|
||||
})
|
||||
|
||||
it('keeps text prompt blocks and drops non-text ones', () => {
|
||||
expect(toAcpPrompt([{ type: 'text', text: 'a' }])).toEqual([{ type: 'text', text: 'a' }])
|
||||
// A non-text harness block (e.g. reasoning) is dropped from the ACP prompt.
|
||||
expect(toAcpPrompt([{ type: 'text', text: 'a' }, { type: 'reasoning', text: 'think' }]))
|
||||
.toEqual([{ type: 'text', text: 'a' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildChildEnv', () => {
|
||||
it('drops credential-shaped ambient vars but keeps the explicit extras', () => {
|
||||
process.env.DSH_ACP_TEST_SECRET_TOKEN = 'leak-me'
|
||||
try {
|
||||
const env = buildChildEnv({ DEEPSEEK_API_KEY: 'explicit' })
|
||||
// The credential-shaped ambient var is scrubbed.
|
||||
expect(env.DSH_ACP_TEST_SECRET_TOKEN).toBeUndefined()
|
||||
// The explicitly-supplied key survives (an opt-in for the child's creds).
|
||||
expect(env.DEEPSEEK_API_KEY).toBe('explicit')
|
||||
// A normal ambient var is forwarded.
|
||||
expect(SENSITIVE_ENV_PATTERN.test('PATH')).toBe(false)
|
||||
expect(env.PATH).toBe(process.env.PATH)
|
||||
} finally {
|
||||
delete process.env.DSH_ACP_TEST_SECRET_TOKEN
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('dsh-subagent-acp', () => {
|
||||
it('drives a child process to completion and returns its streamed output', async () => {
|
||||
const ctx = await setup({ MOCK_TEXT: 'hello from acp child', MOCK_STOP: 'end_turn' })
|
||||
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'do X' }], parent: fakeParent })
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(text(result.output)).toBe('hello from acp child')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('maps a max_tokens stop reason', async () => {
|
||||
const ctx = await setup({ MOCK_TEXT: 'cut off', MOCK_STOP: 'max_tokens' })
|
||||
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('max-tokens')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('maps a refusal stop reason', async () => {
|
||||
const ctx = await setup({ MOCK_TEXT: '', MOCK_STOP: 'refusal' })
|
||||
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('refusal')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('cancelling a running child settles aborted (session/cancel via run.cancel)', async () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-cancel-'))
|
||||
const readyFile = join(tmp, 'ready')
|
||||
try {
|
||||
const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_READY_FILE: readyFile })
|
||||
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
|
||||
// Wait until the child's prompt is in flight (condition, not a sleep),
|
||||
// then cancel — so we exercise the mid-run session/cancel path.
|
||||
await waitForFile(readyFile)
|
||||
run.cancel('test')
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('aborted')
|
||||
await run.dispose()
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('settles aborted WITHOUT spawning the child when the signal is already aborted', async () => {
|
||||
// A pre-aborted request must not even launch the configured binary. Point
|
||||
// the command at one that would create a sentinel file if it ever ran, and
|
||||
// assert the sentinel never appears.
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-preabort-'))
|
||||
const sentinel = join(tmp, 'spawned')
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const run = startAcpRun(
|
||||
{ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent, signal: controller.signal },
|
||||
// `touch <sentinel>` — runs only if the process is actually spawned.
|
||||
{ command: 'touch', args: [sentinel], cwd: tmp, permission: 'reject', env: {} },
|
||||
)
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('aborted')
|
||||
expect(result.output).toEqual([])
|
||||
// cancel/dispose on the inert run are safe no-ops.
|
||||
run.cancel('noop')
|
||||
await run.dispose()
|
||||
// The binary was never launched — no sentinel.
|
||||
expect(existsSync(sentinel)).toBe(false)
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('dispose escalates SIGTERM → SIGKILL for a child that traps SIGTERM (bounded quiescence)', async () => {
|
||||
// The child traps SIGTERM and keeps its event loop alive, so a graceful
|
||||
// term alone would hang dispose forever. With a short grace, dispose must
|
||||
// escalate to SIGKILL and return once the process is actually gone.
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-trap-'))
|
||||
const ready = join(tmp, 'trap-armed')
|
||||
try {
|
||||
const spec: AcpRunSpec = {
|
||||
command: process.execPath,
|
||||
args: ['--import', tsxLoader, mockServer],
|
||||
cwd: process.cwd(),
|
||||
permission: 'reject',
|
||||
env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready, TSX_TSCONFIG_PATH: repoTsconfig },
|
||||
// Short on BOTH tiers: the trap ignores EOF and SIGTERM, so dispose must
|
||||
// burn the EOF window, then the SIGTERM window, then SIGKILL — keep each
|
||||
// small so the whole ladder finishes well within the 4000ms bound.
|
||||
disposeEofGraceMs: 150,
|
||||
disposeGraceMs: 150,
|
||||
}
|
||||
const run = startAcpRun({ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }, spec)
|
||||
// Wait until the child has BOOTED AND ARMED THE TRAP (a condition, not a
|
||||
// sleep) — otherwise SIGTERM races the trap install and the default handler
|
||||
// terminates the child, never exercising the escalation.
|
||||
await waitForFile(ready)
|
||||
// Don't await result (the child hangs). Dispose must still return promptly
|
||||
// via the SIGKILL escalation — bound it so a regression (no escalation)
|
||||
// fails loud instead of hanging the suite.
|
||||
await expect(Promise.race([
|
||||
run.dispose(),
|
||||
new Promise((_r, reject) => { setTimeout(() => { reject(new Error('dispose did not return — no SIGKILL escalation')) }, 4000) }),
|
||||
])).resolves.toBeUndefined()
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('dispose gives the child an EOF window that outlasts the SIGTERM grace (graceful flush)', async () => {
|
||||
// The real acp-agent flushes ASYNCHRONOUSLY on stdin EOF (its bridge tears
|
||||
// down on connection close, NOT on a signal) — and it has no SIGTERM handler.
|
||||
// Its EOF teardown can itself await a signal-trapping grandchild (a bash
|
||||
// subprocess in its own SIGTERM→SIGKILL grace) plus a flush, so the EOF window
|
||||
// must be a SEPARATE, WIDER grace than the SIGTERM tier — not the same value.
|
||||
// The mock models a flush that takes LONGER than the SIGTERM grace but well
|
||||
// under the EOF grace: it lands only because tier 1 waits eofGraceMs, not
|
||||
// graceMs. (If dispose reused the small SIGTERM grace for the EOF wait — the
|
||||
// round-2 bug — SIGTERM would fire mid-flush and the marker would be missing.)
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-eof-'))
|
||||
const ready = join(tmp, 'ready')
|
||||
const flushed = join(tmp, 'flushed')
|
||||
try {
|
||||
const spec: AcpRunSpec = {
|
||||
command: process.execPath,
|
||||
args: ['--import', tsxLoader, mockServer],
|
||||
cwd: process.cwd(),
|
||||
permission: 'reject',
|
||||
// MOCK_HANG so the prompt never resolves on its own — we tear down a live
|
||||
// child. The flush beat (400ms) outlasts the 50ms SIGTERM grace but fits
|
||||
// the 2000ms EOF grace; the marker lands iff the EOF tier honored its own
|
||||
// wider grace.
|
||||
env: {
|
||||
MOCK_HANG: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready,
|
||||
MOCK_FLUSH_ON_EOF: flushed, MOCK_FLUSH_DELAY_MS: '400', TSX_TSCONFIG_PATH: repoTsconfig,
|
||||
},
|
||||
disposeEofGraceMs: 2000,
|
||||
disposeGraceMs: 50,
|
||||
}
|
||||
const run = startAcpRun({ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }, spec)
|
||||
// Wait until the child is fully booted with its prompt in flight (its ACP
|
||||
// stdin reader is attached), so dispose's stdin EOF reaches a live child.
|
||||
await waitForFile(ready)
|
||||
await run.dispose()
|
||||
// dispose returned via the natural-exit tier — the EOF-driven flush landed
|
||||
// despite taking longer than the SIGTERM grace.
|
||||
expect(existsSync(flushed)).toBe(true)
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('escalates to SIGTERM for a child that ignores EOF but is not SIGTERM-trapping', async () => {
|
||||
// A child that keeps its loop alive past stdin EOF (so the graceful window
|
||||
// times out) but exits cooperatively on SIGTERM must die on the SIGTERM tier
|
||||
// — dispose returns there, never reaching the SIGKILL tier. The child touches
|
||||
// a SIGTERM marker from its signal handler: SIGKILL is uncatchable, so if
|
||||
// dispose had skipped the middle rung (EOF→SIGKILL) the handler would never
|
||||
// run and the marker would be absent — making this a GENUINE middle-tier guard.
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-ignore-eof-'))
|
||||
const ready = join(tmp, 'ready')
|
||||
const sigterm = join(tmp, 'sigterm')
|
||||
try {
|
||||
const spec: AcpRunSpec = {
|
||||
command: process.execPath,
|
||||
args: ['--import', tsxLoader, mockServer],
|
||||
cwd: process.cwd(),
|
||||
permission: 'reject',
|
||||
env: {
|
||||
MOCK_HANG: '1', MOCK_IGNORE_EOF: '1', MOCK_TEXT: 'x',
|
||||
MOCK_READY_FILE: ready, MOCK_SIGTERM_FILE: sigterm, TSX_TSCONFIG_PATH: repoTsconfig,
|
||||
},
|
||||
// Tiny EOF grace so the ignored-EOF window elapses fast, then SIGTERM.
|
||||
disposeEofGraceMs: 150,
|
||||
disposeGraceMs: 2000,
|
||||
}
|
||||
const run = startAcpRun({ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }, spec)
|
||||
await waitForFile(ready)
|
||||
// Bound it so a hang fails loud rather than stalling the suite.
|
||||
await expect(Promise.race([
|
||||
run.dispose(),
|
||||
new Promise((_r, reject) => { setTimeout(() => { reject(new Error('dispose did not return')) }, 5000) }),
|
||||
])).resolves.toBeUndefined()
|
||||
// The child caught SIGTERM and exited — proof the middle rung fired (not a
|
||||
// jump straight to the uncatchable SIGKILL).
|
||||
expect(existsSync(sigterm)).toBe(true)
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('honors a cancel that races AHEAD of newSession (no session id yet) without running the prompt', async () => {
|
||||
// Gate the child at newSession: it signals `ready` and blocks until `go`.
|
||||
// We cancel WHILE newSession is pending (sessionId still undefined, so the
|
||||
// backend cannot send session/cancel) — the `cancelled` flag alone must
|
||||
// settle the run aborted after newSession resolves, never issuing the prompt.
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-early-'))
|
||||
const ready = join(tmp, 'ready')
|
||||
const go = join(tmp, 'go')
|
||||
try {
|
||||
const ctx = await setup({ MOCK_NEWSESSION_READY: ready, MOCK_NEWSESSION_GO: go, MOCK_TEXT: 'should not run' })
|
||||
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
|
||||
await waitForFile(ready) // newSession is now in flight, sessionId undefined
|
||||
run.cancel('early') // sets cancelled; cannot send session/cancel yet
|
||||
writeFileSync(go, 'go') // let newSession resolve
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('aborted')
|
||||
expect(result.output).toEqual([])
|
||||
await run.dispose()
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('bridges the request signal to a session/cancel mid-run', async () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-signal-'))
|
||||
const readyFile = join(tmp, 'ready')
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_READY_FILE: readyFile })
|
||||
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent, signal: controller.signal })
|
||||
await waitForFile(readyFile)
|
||||
controller.abort()
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('aborted')
|
||||
await run.dispose()
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('auto-rejects a permission prompt by default (child settles cancelled→aborted)', async () => {
|
||||
const ctx = await setup({ MOCK_TEXT: 'x', MOCK_PERMISSION: '1' }, 'reject')
|
||||
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
|
||||
const result = await run.result
|
||||
// The child asked permission, the backend rejected, the child returned cancelled.
|
||||
expect(result.stopReason).toBe('aborted')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('auto-approves a permission prompt under the allow policy', async () => {
|
||||
const ctx = await setup({ MOCK_TEXT: 'approved answer', MOCK_PERMISSION: '1', MOCK_STOP: 'end_turn' }, 'allow')
|
||||
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(text(result.output)).toBe('approved answer')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('falls back to cancelled under the allow policy when the child offers no allow option', async () => {
|
||||
// The child asks permission but offers ONLY reject-shaped options, so an
|
||||
// allow-policy client finds nothing to select and must answer cancelled.
|
||||
const ctx = await setup({ MOCK_PERMISSION: '1', MOCK_NO_ALLOW: '1' }, 'allow')
|
||||
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('aborted')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('consumes a non-message update (a thought) without adding it to the output', async () => {
|
||||
// The child streams an agent_thought_chunk before its answer; the backend
|
||||
// must consume it but NOT include it in the result output.
|
||||
const ctx = await setup({ MOCK_THOUGHT: '1', MOCK_TEXT: 'final answer', MOCK_STOP: 'end_turn' })
|
||||
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
// Only the message text, NOT the thought.
|
||||
expect(text(result.output)).toBe('final answer')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('resolves error (not reject) when the spawn command does not exist', async () => {
|
||||
// Direct startAcpRun with NO onError sink — the catch must still flatten the
|
||||
// spawn failure to `error` (the onError call is optional, covering the
|
||||
// absent-sink branch).
|
||||
const run = startAcpRun(
|
||||
{ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent },
|
||||
{ command: '/nonexistent/acp-agent-binary', args: [], cwd: process.cwd(), permission: 'reject', env: {} },
|
||||
)
|
||||
const result = await run.result
|
||||
// The seam contract: a child-level failure resolves error, never rejects.
|
||||
expect(result.stopReason).toBe('error')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('resolves error via the provider (real load path) when the command does not exist', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(acp, {
|
||||
providerName: 'acp',
|
||||
command: '/nonexistent/acp-agent-binary',
|
||||
args: [],
|
||||
permission: 'reject',
|
||||
env: {},
|
||||
})
|
||||
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('error')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('reports a flattened child failure through onError (preserved, not silently lost)', async () => {
|
||||
// The seam forbids `result` rejecting, so a child-level failure is flattened
|
||||
// to a stop reason — onError must still surface the original error so a real
|
||||
// fault is logged, not swallowed. A nonexistent command triggers the spawn
|
||||
// failure path; the spy records the error + the chosen stop reason.
|
||||
const errors: { message: string; stopReason: string }[] = []
|
||||
const run = startAcpRun(
|
||||
{ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent },
|
||||
{
|
||||
command: '/nonexistent/acp-agent-binary',
|
||||
args: [],
|
||||
cwd: process.cwd(),
|
||||
permission: 'reject',
|
||||
env: {},
|
||||
onError: (error, stopReason) => { errors.push({ message: error.message, stopReason }) },
|
||||
},
|
||||
)
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]!.stopReason).toBe('error')
|
||||
expect(errors[0]!.message.length).toBeGreaterThan(0)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('settles aborted when the child crashes (tears the pipe) AFTER a cancel', async () => {
|
||||
// The child hangs, we cancel, and instead of answering the child exits hard
|
||||
// — the pending prompt RPC rejects. With a cancel already requested, the
|
||||
// backend's catch path must settle `aborted` (the failure is the cancel
|
||||
// surfacing as a torn pipe), not `error`.
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-crash-'))
|
||||
const ready = join(tmp, 'ready')
|
||||
try {
|
||||
const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_CRASH_ON_CANCEL: '1', MOCK_READY_FILE: ready })
|
||||
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
|
||||
await waitForFile(ready)
|
||||
run.cancel('crash it')
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('aborted')
|
||||
await run.dispose()
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('settles aborted on cancel even when the child IGNORES session/cancel (non-cooperative)', async () => {
|
||||
// The contract: run.cancel() → result settles `aborted`. A child that hangs
|
||||
// its prompt AND ignores session/cancel must not wedge the parent — the
|
||||
// backend's own cancel-settle path resolves `aborted` without the child's
|
||||
// cooperation, and dispose() still reaps the process.
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-ignorecancel-'))
|
||||
const ready = join(tmp, 'ready')
|
||||
try {
|
||||
const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_IGNORE_CANCEL: '1', MOCK_READY_FILE: ready })
|
||||
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
|
||||
await waitForFile(ready)
|
||||
run.cancel('test')
|
||||
// Bound it: a regression (cancel only notifies the child, which ignores it)
|
||||
// would hang result forever — fail loud instead of stalling the suite.
|
||||
const result = await Promise.race([
|
||||
run.result,
|
||||
new Promise<never>((_r, reject) => { setTimeout(() => { reject(new Error('result did not settle on cancel — backend waited on the child')) }, 4000) }),
|
||||
])
|
||||
expect(result.stopReason).toBe('aborted')
|
||||
await run.dispose()
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('advertises no start-time capabilities (out-of-process child)', async () => {
|
||||
const ctx = await setup()
|
||||
const provider = ctx.subagents.getProvider('acp')!
|
||||
expect(provider.capabilities).toEqual({ outputSchema: false, depthLimit: false, toolFilter: false })
|
||||
})
|
||||
|
||||
it('unregisters the provider when its fiber is disposed (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
const fiber = await ctx.plugin(acp, { providerName: 'acp', command: 'x', args: [], permission: 'reject', env: {} })
|
||||
expect(ctx.subagents.list()).toEqual(['acp'])
|
||||
await fiber.dispose()
|
||||
expect(ctx.subagents.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('has the namespace-plugin export shape (no stray default)', () => {
|
||||
expect('default' in acp).toBe(false)
|
||||
expect(acp.name).toBe('subagent-acp')
|
||||
expect(acp.inject).toEqual(['subagents'])
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(acp) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(acp)
|
||||
expect(unwrapped.name).toBe('subagent-acp')
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
30
packages/subagent/subagent-acp/tsconfig.json
Normal file
30
packages/subagent/subagent-acp/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../subagent"
|
||||
}
|
||||
]
|
||||
}
|
||||
23
packages/subagent/subagent-fork/README.md
Normal file
23
packages/subagent/subagent-fork/README.md
Normal file
@@ -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.
|
||||
48
packages/subagent/subagent-fork/package.json
Normal file
48
packages/subagent/subagent-fork/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"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/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"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-inprocess": "^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-inprocess": "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"
|
||||
}
|
||||
}
|
||||
81
packages/subagent/subagent-fork/src/index.ts
Normal file
81
packages/subagent/subagent-fork/src/index.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* The in-process FORK subagent backend: registers a {@link SubagentProvider} on
|
||||
* `ctx.subagents` that runs each child as a child {@link Agent} SEEDED with a
|
||||
* prefix of the parent's session log — so the child inherits the parent's
|
||||
* conversation context instead of starting fresh. The run mechanics live in
|
||||
* `@deepseek-ai/dsh-subagent-inprocess` ({@link startInProcessRun}); this
|
||||
* backend just computes the seed. The spawn backend is an independent peer over
|
||||
* the same driver.
|
||||
*
|
||||
* The seed boundary is the crux: at the moment a subagent tool's `execute`
|
||||
* runs, the parent's CURRENT turn is open and unbalanced (it holds the
|
||||
* `assistant/message` with this spawn's tool-call, plus the dangling `tool/call`
|
||||
* with no `tool/result`). Seeding that raw prefix gives the child an open turn
|
||||
* the session constructor and the dev-mode invariants replay REJECT. So the
|
||||
* fork seeds only the **balanced completed-turn prefix**: the parent's log up
|
||||
* to and including its last `turn/end`, excluding the in-flight turn entirely.
|
||||
*
|
||||
* Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default.
|
||||
*
|
||||
* @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-inprocess'
|
||||
|
||||
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<Config> = 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))
|
||||
}
|
||||
99
packages/subagent/subagent-fork/tests/multi-subagent.spec.ts
Normal file
99
packages/subagent/subagent-fork/tests/multi-subagent.spec.ts
Normal file
@@ -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<typeof MockAdapter>[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)
|
||||
})
|
||||
})
|
||||
190
packages/subagent/subagent-fork/tests/subagent-fork.spec.ts
Normal file
190
packages/subagent/subagent-fork/tests/subagent-fork.spec.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
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 type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import * as fork from '../src/index.ts'
|
||||
import { completedTurnPrefix } from '../src/index.ts'
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[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
|
||||
* 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)
|
||||
// The seed boundary is recorded on the header (= the seeded prefix length),
|
||||
// so a reload / replay harness can tell the inherited prefix from the
|
||||
// child's own events.
|
||||
expect(child.session.header.seedLength).toBe(parentPrefixLen)
|
||||
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('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 })
|
||||
})
|
||||
|
||||
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<string, unknown>
|
||||
expect(unwrapped).toBe(fork)
|
||||
expect(unwrapped.name).toBe('subagent-fork')
|
||||
expect(unwrapped.inject).toEqual(['subagents', 'agents'])
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
33
packages/subagent/subagent-fork/tsconfig.json
Normal file
33
packages/subagent/subagent-fork/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../subagent"
|
||||
},
|
||||
{
|
||||
"path": "../subagent-inprocess"
|
||||
}
|
||||
]
|
||||
}
|
||||
28
packages/subagent/subagent-inprocess/README.md
Normal file
28
packages/subagent/subagent-inprocess/README.md
Normal file
@@ -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`.
|
||||
42
packages/subagent/subagent-inprocess/package.json
Normal file
42
packages/subagent/subagent-inprocess/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"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/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
195
packages/subagent/subagent-inprocess/src/index.ts
Normal file
195
packages/subagent/subagent-inprocess/src/index.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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'
|
||||
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())
|
||||
// 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
|
||||
// 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,
|
||||
// Record the seed boundary so a reload (and a replay harness) can tell the
|
||||
// inherited prefix from the child's OWN events. 0 for a fresh spawn.
|
||||
...seedLength > 0 ? { seedLength } : {},
|
||||
},
|
||||
...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).
|
||||
// `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<SubagentResult> = (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)
|
||||
} finally {
|
||||
request.signal?.removeEventListener('abort', onAbort)
|
||||
}
|
||||
})()
|
||||
|
||||
return {
|
||||
id: childId,
|
||||
result,
|
||||
cancel(reason?: string): void {
|
||||
requestCancel(reason ?? 'subagent cancelled')
|
||||
},
|
||||
async dispose(): Promise<void> {
|
||||
request.signal?.removeEventListener('abort', onAbort)
|
||||
await handle.dispose()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, 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) }
|
||||
}
|
||||
@@ -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<typeof MockAdapter>[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()
|
||||
})
|
||||
})
|
||||
30
packages/subagent/subagent-inprocess/tsconfig.json
Normal file
30
packages/subagent/subagent-inprocess/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../subagent"
|
||||
}
|
||||
]
|
||||
}
|
||||
19
packages/subagent/subagent-spawn/README.md
Normal file
19
packages/subagent/subagent-spawn/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# @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.
|
||||
|
||||
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)` 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
|
||||
|
||||
`{ 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`). |
|
||||
49
packages/subagent/subagent-spawn/package.json
Normal file
49
packages/subagent/subagent-spawn/package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"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/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent-inprocess": "^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-subagent-inprocess": "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"
|
||||
}
|
||||
}
|
||||
54
packages/subagent/subagent-spawn/src/index.ts
Normal file
54
packages/subagent/subagent-spawn/src/index.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* The in-process SPAWN subagent backend: registers a {@link SubagentProvider}
|
||||
* on `ctx.subagents` that runs each child as a FRESH child {@link Agent} on the
|
||||
* same cordis context (its own session, own system prompt, zero parent
|
||||
* context). The cheapest transport, reusing the agent factory's quiescent
|
||||
* teardown.
|
||||
*
|
||||
* The run mechanics live in `@deepseek-ai/dsh-subagent-inprocess`
|
||||
* ({@link startInProcessRun}); this backend just passes NO seed (a fresh
|
||||
* child). The fork backend is an independent peer over the same driver.
|
||||
*
|
||||
* 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 '@deepseek-ai/dsh-subagent-inprocess'
|
||||
|
||||
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<Config> = 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))
|
||||
}
|
||||
49
packages/subagent/subagent-spawn/tests/harness.ts
Normal file
49
packages/subagent/subagent-spawn/tests/harness.ts
Normal file
@@ -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<Context> {
|
||||
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<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
dispose()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
54
packages/subagent/subagent-spawn/tests/spawn.e2e.ts
Normal file
54
packages/subagent/subagent-spawn/tests/spawn.e2e.ts
Normal file
@@ -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)
|
||||
})
|
||||
271
packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts
Normal file
271
packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
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 '@deepseek-ai/dsh-subagent-inprocess'
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[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('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
|
||||
// `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()
|
||||
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<string, unknown>
|
||||
expect(unwrapped).toBe(spawn)
|
||||
expect(unwrapped.name).toBe('subagent-spawn')
|
||||
expect(unwrapped.inject).toEqual(['subagents', 'agents'])
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
27
packages/subagent/subagent-spawn/tsconfig.json
Normal file
27
packages/subagent/subagent-spawn/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../subagent"
|
||||
},
|
||||
{
|
||||
"path": "../subagent-inprocess"
|
||||
}
|
||||
]
|
||||
}
|
||||
39
packages/subagent/subagent/README.md
Normal file
39
packages/subagent/subagent/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# @deepseek-ai/dsh-subagent
|
||||
|
||||
The **subagent seam**: an abstract `SubagentService` (`ctx.subagents`) for an agent delegating work to another agent. A *subagent* is a child agent; a `SubagentProvider` is one transport for running it.
|
||||
|
||||
This package is the interface third of the capability seam, split so each concern evolves (and swaps) independently:
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-subagent` (this) | the interface: registry service + vocabulary types |
|
||||
| `@deepseek-ai/dsh-subagent-spawn` | an implementation: fresh in-process child |
|
||||
| `@deepseek-ai/dsh-subagent-fork` | an implementation: in-process child seeded from the parent's log |
|
||||
| `@deepseek-ai/dsh-subagent-acp` | an implementation: ACP client driving another process |
|
||||
| `@deepseek-ai/dsh-tool-subagent` | the model-facing tool over `ctx.subagents` |
|
||||
|
||||
Unlike the bash seam (one executor per context, second load throws), **multiple providers coexist** here. Each registers under a unique name and a caller picks one by name — the shape mirrors the LLM adapter registry (`LlmService.registerAdapter`), not the single-service bash executor. This is the requirement that rules out the bash shape: an agent may want an in-process child for a cheap subtask and an out-of-process ACP child for an isolated one, in the same runtime.
|
||||
|
||||
## Service API (`ctx.subagents`)
|
||||
|
||||
| Member | Semantics |
|
||||
|---|---|
|
||||
| `registerProvider(provider)` | Register under `provider.name`. Throws `SubagentError('DUPLICATE_PROVIDER')` on a name clash. Effect-scoped (HMR-safe); returns the disposer. |
|
||||
| `getProvider(name)` | Look up a provider (`undefined` if absent). |
|
||||
| `list()` | Registered provider names (insertion order). |
|
||||
| `start(name, request)` | Resolve the provider (`NO_PROVIDER` if absent), validate every requested START-TIME capability (`UNSUPPORTED_CAPABILITY` for the first unmet one — before any child is created), then delegate to `provider.start` and emit `subagent/start` / `subagent/end` around the run. |
|
||||
|
||||
## Capabilities: two kinds, discovered two ways
|
||||
|
||||
- **Start-time features** (`outputSchema`, `depthLimit`, `toolFilter`) are a static `provider.capabilities` descriptor, checked by the service BEFORE a run exists. A request that needs one the provider lacks is **rejected loud** (`UNSUPPORTED_CAPABILITY`), never accepted-then-ignored.
|
||||
- **Runtime features** (steering, resume) are **optional methods** on `SubagentRun` (`sendMessage?`, `resume?`). The method's presence IS the capability; TS narrowing is the discovery mechanism — a consumer cannot call an absent method without narrowing first, so there is no silent degradation path.
|
||||
|
||||
## Run lifecycle
|
||||
|
||||
`provider.start(request)` returns a `SubagentRun`: a handle with a `result` promise, `cancel()`, `dispose()`, and the optional runtime methods. `result` resolves with a `SubagentResult` (`output`, optional `structured`, `stopReason`) — it does **not** reject on a child-level failure (a model/transport failure resolves with `stopReason: 'error'`), so the consumer maps a non-`completed` reason to an `isError` tool result. The consumer MUST `dispose()` on every path (success, error, abort) to reach child quiescence and avoid leaking an idle child / session.
|
||||
|
||||
## Scope (first cut)
|
||||
|
||||
The consumer collects **synchronously**: it starts a run and awaits `result`. Steering (`sendMessage`) is part of the contract but intentionally unused. Background / poll / spill semantics are deferred to a future redesign unifying long-running-tool handling across subagents and bash. See the RFC: [docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md).
|
||||
|
||||
See `src/types.ts` for the full contracts.
|
||||
36
packages/subagent/subagent/package.json
Normal file
36
packages/subagent/subagent/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-subagent",
|
||||
"description": "Abstract subagent seam (ctx.subagents): named-provider registry for delegating to child agents",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
224
packages/subagent/subagent/src/index.ts
Normal file
224
packages/subagent/subagent/src/index.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* The subagent seam (`ctx.subagents`): a named-provider registry plus a
|
||||
* capability-validating `start` surface. A subagent is an agent delegating
|
||||
* work to another agent; a {@link SubagentProvider} is one transport for
|
||||
* running that child (in-process spawn/fork, ACP to another process, and —
|
||||
* later — A2A, the Codex app-server, the Claude Code Agent SDK).
|
||||
*
|
||||
* Unlike the bash seam (one executor per context, second load throws), MULTIPLE
|
||||
* providers coexist here: each registers under a unique name and a caller picks
|
||||
* one by name. The shape mirrors the LLM adapter registry
|
||||
* (`LlmService.registerAdapter`), not the single-service bash executor.
|
||||
*
|
||||
* This package is the INTERFACE third of the capability seam. Implementations
|
||||
* (`@deepseek-ai/dsh-subagent-spawn`, `-fork`, `-acp`) and the model-facing
|
||||
* consumer (`@deepseek-ai/dsh-tool-subagent`) are separate packages.
|
||||
*
|
||||
* Scope (first cut): the consumer collects synchronously — it starts a run and
|
||||
* awaits {@link SubagentRun.result}. Steering ({@link SubagentRun.sendMessage})
|
||||
* is part of the contract but intentionally unused; background / poll / spill
|
||||
* semantics are deferred to a future redesign that unifies long-running-tool
|
||||
* handling across subagents and bash.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import type {
|
||||
SubagentCapabilities,
|
||||
SubagentProvider,
|
||||
SubagentResult,
|
||||
SubagentRun,
|
||||
SubagentStartRequest,
|
||||
} from './types.ts'
|
||||
|
||||
export type {
|
||||
SubagentCapabilities,
|
||||
SubagentProvider,
|
||||
SubagentResult,
|
||||
SubagentRun,
|
||||
SubagentStartRequest,
|
||||
SubagentStopReason,
|
||||
SubagentStopReasonMap,
|
||||
} from './types.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
subagents: SubagentService
|
||||
}
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* A subagent run started — emitted after the provider is resolved and its
|
||||
* capabilities validated, as the child run begins. Paired with
|
||||
* {@link Events['subagent/end']}.
|
||||
* @mode emit
|
||||
*/
|
||||
'subagent/start'(info: SubagentRunInfo): void
|
||||
/**
|
||||
* A subagent run settled — emitted when {@link SubagentRun.result}
|
||||
* resolves (any stop reason). Paired with {@link Events['subagent/start']}.
|
||||
* @mode emit
|
||||
*/
|
||||
'subagent/end'(info: SubagentRunEndInfo): void
|
||||
}
|
||||
}
|
||||
|
||||
/** Identifying detail for a started subagent run (the `subagent/start` payload). */
|
||||
export interface SubagentRunInfo {
|
||||
/** The provider that started the run. */
|
||||
provider: string
|
||||
/** The child agent's id. */
|
||||
id: AgentId
|
||||
}
|
||||
|
||||
/** Outcome detail for a settled subagent run (the `subagent/end` payload). */
|
||||
export interface SubagentRunEndInfo {
|
||||
/** The provider that ran it. */
|
||||
provider: string
|
||||
/** The child agent's id. */
|
||||
id: AgentId
|
||||
/** The terminal stop reason. */
|
||||
stopReason: SubagentResult['stopReason']
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed error for subagent-seam failures. Extends {@link HarnessError}, so the
|
||||
* `code` string (`DUPLICATE_PROVIDER`, `NO_PROVIDER`, `UNSUPPORTED_CAPABILITY`)
|
||||
* is shared, machine-routable taxonomy.
|
||||
*/
|
||||
export class SubagentError extends HarnessError {
|
||||
constructor(message: string, code: string, options?: ErrorOptions) {
|
||||
super(message, code, options)
|
||||
this.name = 'SubagentError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `subagents` service: a registry of named {@link SubagentProvider}s and a
|
||||
* capability-checked {@link start} surface.
|
||||
*/
|
||||
export class SubagentService extends Service {
|
||||
private providers = new Map<string, SubagentProvider>()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'subagents')
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a provider under its `provider.name`. Throws {@link SubagentError}
|
||||
* (`DUPLICATE_PROVIDER`) if the name is already taken. Effect-scoped: disposed
|
||||
* with the calling fiber (HMR-safe).
|
||||
*/
|
||||
registerProvider(provider: SubagentProvider): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: SubagentService) {
|
||||
if (this.providers.has(provider.name)) {
|
||||
throw new SubagentError(`a subagent provider named "${provider.name}" is already registered`, 'DUPLICATE_PROVIDER')
|
||||
}
|
||||
this.providers.set(provider.name, provider)
|
||||
yield () => {
|
||||
this.providers.delete(provider.name)
|
||||
}
|
||||
}.bind(this), 'subagents.registerProvider()')
|
||||
// ctx.effect's disposer returns Promise<void>; our disposer API is
|
||||
// synchronous fire-and-forget — discard the (always-resolved) promise.
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/** Look up a registered provider by name (`undefined` if absent). */
|
||||
getProvider(name: string): SubagentProvider | undefined {
|
||||
return this.providers.get(name)
|
||||
}
|
||||
|
||||
/** The names of all registered providers (insertion order). */
|
||||
list(): string[] {
|
||||
return [...this.providers.keys()]
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a subagent run on the named provider. Resolves the provider (throws
|
||||
* `NO_PROVIDER` if absent), validates every requested START-TIME capability
|
||||
* against {@link SubagentProvider.capabilities} (throws `UNSUPPORTED_CAPABILITY`
|
||||
* for the first unmet one — fail loud, before any child is created), then
|
||||
* delegates to {@link SubagentProvider.start} and emits `subagent/start` /
|
||||
* `subagent/end` around the run.
|
||||
*/
|
||||
start(name: string, request: SubagentStartRequest): SubagentRun {
|
||||
const provider = this.providers.get(name)
|
||||
if (!provider) {
|
||||
throw new SubagentError(`no subagent provider registered for "${name}"`, 'NO_PROVIDER')
|
||||
}
|
||||
this.assertCapabilities(provider, request)
|
||||
|
||||
const run = provider.start(request)
|
||||
// Emit `subagent/start` with PER-LISTENER containment (see {@link emitLifecycle}):
|
||||
// the run is already live, so neither a throwing subscriber escaping
|
||||
// `start()` (the caller would never receive the run to dispose it — a leaked
|
||||
// child) NOR one bad subscriber starving the listeners after it is
|
||||
// acceptable. `ctx.emit` halts the dispatch on the first throw, so a single
|
||||
// surrounding try/catch is not enough — each listener is invoked and
|
||||
// contained individually.
|
||||
this.emitLifecycle('subagent/start', { provider: name, id: run.id })
|
||||
// Emit `subagent/end` when the run settles. The result promise does not
|
||||
// reject on a child-level failure (it resolves with stopReason 'error'),
|
||||
// so a rejection here is an infrastructure fault — surface its stop reason
|
||||
// as 'error' for the telemetry event without swallowing the rejection
|
||||
// (the consumer still observes it via `run.result`). Per-listener
|
||||
// containment also keeps a thrown `subagent/end` listener from becoming an
|
||||
// unhandled rejection on this detached `.then`.
|
||||
void run.result.then(
|
||||
(result) => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: result.stopReason }) },
|
||||
() => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }) },
|
||||
)
|
||||
return run
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a `subagent/*` lifecycle event with PER-LISTENER containment: dispatch
|
||||
* each subscriber individually and log (never propagate) a thrown one, so one
|
||||
* bad subscriber can neither strand the already-live run, surface as an
|
||||
* unhandled rejection on the detached settle hook, NOR starve the listeners
|
||||
* registered after it. A single try/catch around `ctx.emit` would not do the
|
||||
* last part — cordis `emit` runs listeners in a `.map(cb => cb())` that halts
|
||||
* on the first throw — so this resolves the listener callbacks via
|
||||
* `ctx.events.dispatch` and contains each call, the same guarantee
|
||||
* `BashExecutor.notifyTaskDone` gives its own listener set.
|
||||
*/
|
||||
private emitLifecycle(
|
||||
name: 'subagent/start' | 'subagent/end',
|
||||
info: SubagentRunInfo | SubagentRunEndInfo,
|
||||
): void {
|
||||
for (const callback of this.ctx.events.dispatch('emit', [name, info])) {
|
||||
try {
|
||||
callback(info)
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`subagent: ${name} listener threw: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a request that needs a start-time capability the provider lacks.
|
||||
* Each optional request field maps to one {@link SubagentCapabilities} flag;
|
||||
* the first unmet one throws `UNSUPPORTED_CAPABILITY`.
|
||||
*/
|
||||
private assertCapabilities(provider: SubagentProvider, request: SubagentStartRequest): void {
|
||||
const needs: { when: boolean; cap: keyof SubagentCapabilities }[] = [
|
||||
{ when: request.outputSchema !== undefined, cap: 'outputSchema' },
|
||||
{ when: request.maxDepth !== undefined, cap: 'depthLimit' },
|
||||
{ when: request.toolFilter !== undefined, cap: 'toolFilter' },
|
||||
]
|
||||
for (const { when, cap } of needs) {
|
||||
if (when && !provider.capabilities[cap]) {
|
||||
throw new SubagentError(
|
||||
`subagent provider "${provider.name}" does not support the "${cap}" capability`,
|
||||
'UNSUPPORTED_CAPABILITY',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default SubagentService
|
||||
172
packages/subagent/subagent/src/types.ts
Normal file
172
packages/subagent/subagent/src/types.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Subagent seam vocabulary: the request/result/capability types a
|
||||
* {@link SubagentProvider} consumes and produces. No runtime code — types
|
||||
* only, per the package convention.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent/types
|
||||
*/
|
||||
|
||||
import type { Agent, AgentId, AgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SchemaSpec } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/**
|
||||
* Which START-TIME features a provider supports. Checked by the service
|
||||
* BEFORE delegating to {@link SubagentProvider.start}: a request that needs a
|
||||
* capability the chosen provider lacks is rejected with a typed error rather
|
||||
* than accepted-then-ignored (the "fail loud, no silent degradation" rule).
|
||||
*
|
||||
* Start-time features live here (a static descriptor) because they must be
|
||||
* checked before a run exists. RUNTIME features (steering, resume) are instead
|
||||
* modeled as OPTIONAL METHODS on {@link SubagentRun}: the method's presence IS
|
||||
* the capability, and TS narrowing is the discovery mechanism — a consumer
|
||||
* cannot call an absent method without narrowing first.
|
||||
*/
|
||||
export interface SubagentCapabilities {
|
||||
/** Honor {@link SubagentStartRequest.outputSchema} (structured final output). */
|
||||
outputSchema: boolean
|
||||
/** Enforce {@link SubagentStartRequest.maxDepth} (recursion cap). */
|
||||
depthLimit: boolean
|
||||
/** Enforce {@link SubagentStartRequest.toolFilter} (child tool scoping). */
|
||||
toolFilter: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* What a caller asks for when starting a subagent. The tool layer builds this
|
||||
* from the model's `{ description, prompt }` plus its own config; the service
|
||||
* validates {@link SubagentCapabilities} against the named provider, then
|
||||
* passes it to {@link SubagentProvider.start}.
|
||||
*/
|
||||
export interface SubagentStartRequest {
|
||||
/** The task/prompt for the child agent (a user message in the child session). */
|
||||
prompt: ContentBlock[]
|
||||
/**
|
||||
* The spawning ("parent") agent — the one whose tool call started this
|
||||
* subagent. REQUIRED: in-process backends read `parent.session.header` for
|
||||
* the working directory, the `parentSession` lineage to stamp on the child,
|
||||
* and the parent's delegation depth. Out-of-process backends (ACP) ignore it.
|
||||
*/
|
||||
parent: Agent
|
||||
/**
|
||||
* Cancellation signal from the spawning context (the tool's `exec.signal`).
|
||||
* A provider that honors it aborts the child when the signal fires; the
|
||||
* consumer also bridges it to {@link SubagentRun.cancel} explicitly.
|
||||
*/
|
||||
signal?: AbortSignal
|
||||
/** Per-child agent options (model, system prompt). */
|
||||
agentOptions?: AgentOptions
|
||||
/**
|
||||
* Optional structured-output schema. When set AND the provider's
|
||||
* {@link SubagentCapabilities.outputSchema} is `true`, the child's final
|
||||
* answer is shaped to this schema and surfaced as {@link SubagentResult.structured}.
|
||||
* Requesting it against a provider that lacks the capability is rejected at start.
|
||||
*/
|
||||
outputSchema?: SchemaSpec
|
||||
/**
|
||||
* Optional recursion cap (max delegation depth below this child). Requires
|
||||
* {@link SubagentCapabilities.depthLimit}; rejected at start otherwise.
|
||||
*/
|
||||
maxDepth?: number
|
||||
/**
|
||||
* Optional child tool scoping. Requires {@link SubagentCapabilities.toolFilter};
|
||||
* rejected at start otherwise.
|
||||
*/
|
||||
toolFilter?: { allow?: string[]; deny?: string[] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a subagent run ended. Merge-extensible (a backend may add variants);
|
||||
* consumers branch on the known cases and fall through `default`. The known
|
||||
* cases mirror the harness turn-end vocabulary so the tool layer can map a
|
||||
* non-`completed` result to an `isError` tool result.
|
||||
*/
|
||||
export interface SubagentStopReasonMap {
|
||||
/** The child finished its turn normally. */
|
||||
completed: 'completed'
|
||||
/** The run was cancelled (parent signal, explicit `cancel()`, or peer cancel). */
|
||||
aborted: 'aborted'
|
||||
/** The child failed (model error, transport error). */
|
||||
error: 'error'
|
||||
/** The child hit its token ceiling before finishing. */
|
||||
'max-tokens': 'max-tokens'
|
||||
/** The child declined the task. */
|
||||
refusal: 'refusal'
|
||||
}
|
||||
|
||||
export type SubagentStopReason = SubagentStopReasonMap[keyof SubagentStopReasonMap]
|
||||
|
||||
/**
|
||||
* The terminal outcome of a subagent run, resolved by {@link SubagentRun.result}.
|
||||
*/
|
||||
export interface SubagentResult {
|
||||
/** The child's final assistant output (the last assistant message's content). */
|
||||
output: ContentBlock[]
|
||||
/**
|
||||
* The structured result, present IFF the request carried an `outputSchema`
|
||||
* AND the provider honored it. Shape is validated against the request schema
|
||||
* by the provider; `unknown` here because the seam is schema-agnostic.
|
||||
*/
|
||||
structured?: unknown
|
||||
/** Why the run ended. A non-`completed` reason means `output` may be partial. */
|
||||
stopReason: SubagentStopReason
|
||||
}
|
||||
|
||||
/**
|
||||
* A live subagent run: a handle the consumer holds while a child executes.
|
||||
* Returned by {@link SubagentProvider.start} (via the service). The consumer
|
||||
* awaits {@link result}, may {@link cancel} mid-flight, and MUST {@link dispose}
|
||||
* on every path to reach child quiescence (no leaked idle child / session).
|
||||
*
|
||||
* {@link sendMessage} and {@link resume} are OPTIONAL: a provider that supports
|
||||
* the runtime capability defines the method; one that doesn't omits it. The
|
||||
* presence of the method IS the capability — narrow before calling.
|
||||
*/
|
||||
export interface SubagentRun {
|
||||
/** 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
|
||||
* settles. Does NOT reject on a child-level failure — a model/transport
|
||||
* failure resolves with `stopReason: 'error'` so the consumer maps it to an
|
||||
* `isError` tool result. Rejects only on an infrastructure fault the seam
|
||||
* cannot represent as a stop reason.
|
||||
*/
|
||||
readonly result: Promise<SubagentResult>
|
||||
/** Request cancellation of the in-flight run; {@link result} settles `aborted`. */
|
||||
cancel(reason?: string): void
|
||||
/**
|
||||
* Reach child quiescence and release the run's resources (in-process: dispose
|
||||
* the owned agent handle and remove its session; ACP: kill the subprocess).
|
||||
* Idempotent; awaits the child actually stopping, not merely requesting it.
|
||||
*/
|
||||
dispose(): Promise<void>
|
||||
/**
|
||||
* OPTIONAL (steering capability): send additional content to the running
|
||||
* child between steps. Present only on providers that support live steering.
|
||||
*/
|
||||
sendMessage?(content: ContentBlock[]): void
|
||||
/**
|
||||
* OPTIONAL (resume capability): send a follow-up task to a settled child,
|
||||
* continuing its session, and return a fresh run for the continuation.
|
||||
*/
|
||||
resume?(content: ContentBlock[]): SubagentRun
|
||||
}
|
||||
|
||||
/**
|
||||
* A subagent backend: one transport for running a child agent (in-process
|
||||
* spawn/fork, ACP to another process, …). Implementations register under a
|
||||
* unique name via {@link SubagentService.registerProvider}; multiple providers
|
||||
* coexist in one context (unlike the single-implementation bash seam).
|
||||
*/
|
||||
export interface SubagentProvider {
|
||||
/** Unique registry name (e.g. `spawn`, `fork`, `acp`). */
|
||||
readonly name: string
|
||||
/** The start-time features this provider supports (see {@link SubagentCapabilities}). */
|
||||
readonly capabilities: SubagentCapabilities
|
||||
/**
|
||||
* Start a child run. The service has already validated that every requested
|
||||
* start-time capability is supported, so an implementation may assume e.g.
|
||||
* `request.maxDepth` is honorable when present.
|
||||
*/
|
||||
start(request: SubagentStartRequest): SubagentRun
|
||||
}
|
||||
242
packages/subagent/subagent/tests/service.spec.ts
Normal file
242
packages/subagent/subagent/tests/service.spec.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import SubagentService, {
|
||||
SubagentError,
|
||||
type SubagentCapabilities,
|
||||
type SubagentProvider,
|
||||
type SubagentResult,
|
||||
type SubagentRun,
|
||||
type SubagentStartRequest,
|
||||
} from '@deepseek-ai/dsh-subagent'
|
||||
|
||||
/** A minimal parent Agent stand-in — the service only reads `parent.id`. */
|
||||
function fakeParent(id = 'parent-1'): Agent {
|
||||
return { id: AgentId(id) } as unknown as Agent
|
||||
}
|
||||
|
||||
const ALL_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true }
|
||||
const NO_CAPS: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false }
|
||||
|
||||
/** A scripted provider whose run settles immediately with a fixed result. */
|
||||
class StubProvider implements SubagentProvider {
|
||||
startCount = 0
|
||||
constructor(
|
||||
readonly name: string,
|
||||
readonly capabilities: SubagentCapabilities = ALL_CAPS,
|
||||
private readonly result: SubagentResult = { output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' },
|
||||
) {}
|
||||
|
||||
start(request: SubagentStartRequest): SubagentRun {
|
||||
this.startCount++
|
||||
return {
|
||||
id: AgentId(`child:${this.name}:${request.parent.id}`),
|
||||
result: Promise.resolve(this.result),
|
||||
cancel() {},
|
||||
async dispose() {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function baseRequest(overrides: Partial<SubagentStartRequest> = {}): SubagentStartRequest {
|
||||
return { prompt: [{ type: 'text', text: 'do a thing' }], parent: fakeParent(), ...overrides }
|
||||
}
|
||||
|
||||
describe('SubagentService', () => {
|
||||
it('registers a provider and starts a run on it by name', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
const provider = new StubProvider('alpha')
|
||||
ctx.subagents.registerProvider(provider)
|
||||
|
||||
expect(ctx.subagents.list()).toEqual(['alpha'])
|
||||
expect(ctx.subagents.getProvider('alpha')).toBe(provider)
|
||||
|
||||
const run = ctx.subagents.start('alpha', baseRequest())
|
||||
expect(provider.startCount).toBe(1)
|
||||
await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
|
||||
})
|
||||
|
||||
it('lets multiple providers coexist (the defining requirement)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider(new StubProvider('spawn'))
|
||||
ctx.subagents.registerProvider(new StubProvider('acp'))
|
||||
|
||||
expect(ctx.subagents.list()).toEqual(['spawn', 'acp'])
|
||||
expect(ctx.subagents.getProvider('spawn')).toBeDefined()
|
||||
expect(ctx.subagents.getProvider('acp')).toBeDefined()
|
||||
})
|
||||
|
||||
it('throws NO_PROVIDER when starting on an unregistered name', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
try {
|
||||
ctx.subagents.start('missing', baseRequest())
|
||||
expect.fail('expected NO_PROVIDER')
|
||||
} catch (error: unknown) {
|
||||
expect(error).toBeInstanceOf(SubagentError)
|
||||
expect((error as SubagentError).code).toBe('NO_PROVIDER')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects duplicate provider names with DUPLICATE_PROVIDER', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider(new StubProvider('dup'))
|
||||
try {
|
||||
ctx.subagents.registerProvider(new StubProvider('dup'))
|
||||
expect.fail('expected DUPLICATE_PROVIDER')
|
||||
} catch (error: unknown) {
|
||||
expect(error).toBeInstanceOf(SubagentError)
|
||||
expect((error as SubagentError).code).toBe('DUPLICATE_PROVIDER')
|
||||
}
|
||||
})
|
||||
|
||||
it('unregisters a provider when its owning fiber is disposed (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.subagents.registerProvider(new StubProvider('scoped'))
|
||||
}, { inject: ['subagents'] }))
|
||||
expect(ctx.subagents.list()).toEqual(['scoped'])
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.subagents.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('re-registers a name after its prior registration is disposed (not wedged)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
|
||||
const dispose = ctx.subagents.registerProvider(new StubProvider('reuse'))
|
||||
expect(ctx.subagents.list()).toEqual(['reuse'])
|
||||
dispose()
|
||||
expect(ctx.subagents.list()).toEqual([])
|
||||
|
||||
const disposeAgain = ctx.subagents.registerProvider(new StubProvider('reuse'))
|
||||
expect(ctx.subagents.list()).toEqual(['reuse'])
|
||||
disposeAgain()
|
||||
expect(ctx.subagents.list()).toEqual([])
|
||||
})
|
||||
|
||||
describe('start-time capability validation (fail loud, before any child)', () => {
|
||||
it.each([
|
||||
{ field: 'outputSchema', request: baseRequest({ outputSchema: { x: { type: 'string' } } }) },
|
||||
{ field: 'maxDepth', request: baseRequest({ maxDepth: 2 }) },
|
||||
{ field: 'toolFilter', request: baseRequest({ toolFilter: { deny: ['bash'] } }) },
|
||||
])('rejects $field against a provider that lacks the capability — before start() runs', ({ request }) => {
|
||||
const ctx = new Context()
|
||||
return ctx.plugin(SubagentService).then(() => {
|
||||
const provider = new StubProvider('weak', NO_CAPS)
|
||||
ctx.subagents.registerProvider(provider)
|
||||
try {
|
||||
ctx.subagents.start('weak', request)
|
||||
expect.fail('expected UNSUPPORTED_CAPABILITY')
|
||||
} catch (error: unknown) {
|
||||
expect(error).toBeInstanceOf(SubagentError)
|
||||
expect((error as SubagentError).code).toBe('UNSUPPORTED_CAPABILITY')
|
||||
}
|
||||
// The child was never started — the check is pre-spawn.
|
||||
expect(provider.startCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('allows a capability request when the provider supports it', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
const provider = new StubProvider('strong', ALL_CAPS)
|
||||
ctx.subagents.registerProvider(provider)
|
||||
ctx.subagents.start('strong', baseRequest({ outputSchema: { x: { type: 'string' } }, maxDepth: 1 }))
|
||||
expect(provider.startCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('emits subagent/start then subagent/end around a run', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider(new StubProvider('events'))
|
||||
|
||||
const started = vi.fn()
|
||||
const ended = vi.fn()
|
||||
ctx.on('subagent/start', started)
|
||||
ctx.on('subagent/end', ended)
|
||||
|
||||
const run = ctx.subagents.start('events', baseRequest())
|
||||
expect(started).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id }))
|
||||
|
||||
await run.result
|
||||
// `subagent/end` fires from a `.then` on the result — let the microtask run.
|
||||
await Promise.resolve()
|
||||
expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id, stopReason: 'completed' }))
|
||||
})
|
||||
|
||||
it('emits subagent/end with stopReason "error" when the run result promise rejects', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
// A provider whose run.result REJECTS (an infrastructure fault — the seam
|
||||
// contract says child-level failures resolve with stopReason 'error', but a
|
||||
// rejection is still surfaced as an 'error' telemetry event).
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'rejecter',
|
||||
capabilities: NO_CAPS,
|
||||
start: () => ({
|
||||
id: AgentId('rej-child'),
|
||||
result: Promise.reject(new Error('infra fault')),
|
||||
cancel() {},
|
||||
dispose: async () => {},
|
||||
}),
|
||||
})
|
||||
|
||||
const ended = vi.fn()
|
||||
ctx.on('subagent/end', ended)
|
||||
const run = ctx.subagents.start('rejecter', baseRequest())
|
||||
// Observe (and swallow) the rejection the consumer would see, then let the
|
||||
// detached `.then` settle the telemetry emit.
|
||||
await run.result.catch(() => {})
|
||||
await Promise.resolve()
|
||||
expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'rejecter', id: run.id, stopReason: 'error' }))
|
||||
})
|
||||
|
||||
it('contains a throwing subagent/start listener per-listener: a later listener still observes the event and start() returns the run', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider(new StubProvider('contain'))
|
||||
// Two listeners; the FIRST throws. Per-listener containment means the second
|
||||
// must STILL run (a single try/catch around ctx.emit would let the first
|
||||
// throw halt the dispatch and starve the second — the round-2 regression).
|
||||
const second = vi.fn()
|
||||
ctx.on('subagent/start', () => { throw new Error('bad start listener') })
|
||||
ctx.on('subagent/start', second)
|
||||
|
||||
const run = ctx.subagents.start('contain', baseRequest())
|
||||
expect(run.id).toBeDefined()
|
||||
expect(second).toHaveBeenCalledWith(expect.objectContaining({ provider: 'contain', id: run.id }))
|
||||
await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
|
||||
})
|
||||
|
||||
it('contains a throwing subagent/end listener per-listener: a later listener still observes the settle, no unhandled rejection', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider(new StubProvider('contain-end'))
|
||||
const second = vi.fn()
|
||||
ctx.on('subagent/end', () => { throw new Error('bad end listener') })
|
||||
ctx.on('subagent/end', second)
|
||||
|
||||
const run = ctx.subagents.start('contain-end', baseRequest())
|
||||
await run.result
|
||||
// Let the detached `.then` + the contained emit run.
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(second).toHaveBeenCalledWith(expect.objectContaining({ provider: 'contain-end', id: run.id, stopReason: 'completed' }))
|
||||
})
|
||||
|
||||
it('SubagentError extends the shared HarnessError base', () => {
|
||||
const err = new SubagentError('boom', 'NO_PROVIDER')
|
||||
expect(err).toBeInstanceOf(HarnessError)
|
||||
expect(err.name).toBe('SubagentError')
|
||||
expect(err.code).toBe('NO_PROVIDER')
|
||||
})
|
||||
})
|
||||
27
packages/subagent/subagent/tsconfig.json
Normal file
27
packages/subagent/subagent/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
}
|
||||
]
|
||||
}
|
||||
19
packages/subagent/tool-subagent/README.md
Normal file
19
packages/subagent/tool-subagent/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# @deepseek-ai/dsh-tool-subagent
|
||||
|
||||
The model-facing `subagent` tool: delegate a self-contained task to a child agent and return its final output. Pure schema + lifecycle shaping over the [`ctx.subagents`](../subagent/README.md) provider registry — an in-process, ACP, or future A2A backend swaps in without changing what the model sees.
|
||||
|
||||
## Provider selection is config, not model-facing
|
||||
|
||||
This plugin binds to **exactly one** provider (`Config.provider`). The model sees only `{ description, prompt }` — there is no provider/type parameter in the schema. To expose more than one transport, load the plugin more than once, each bound to a different provider **and a distinct `toolName`** (the tool registry rejects a duplicate name, so a second load that kept the default `subagent` name would throw). Keeping selection in config (not the schema) is the deliberate split: the *service* holds a multi-provider registry; the *tool* picks one.
|
||||
|
||||
| Config key | Meaning |
|
||||
|---|---|
|
||||
| `provider` (required) | The `ctx.subagents` provider name to start runs on (`spawn`, `fork`, `acp`, …). |
|
||||
| `toolName` | The model-facing tool name to register (default `subagent`). Set a distinct value per load when exposing multiple providers, e.g. `subagent` + `subagent_acp`. |
|
||||
| `agentOptions` | Default per-child `{ model?, systemPrompt? }` applied to every spawned child. |
|
||||
|
||||
## Lifecycle (synchronous collect)
|
||||
|
||||
`execute` starts a run on the configured provider and **awaits `run.result` inside a `try/finally` that always `dispose()`s the run** — the owned child agent/session is torn down on every path (success, error, abort), never leaked. The tool's abort signal (`exec.signal`) is bridged to `run.cancel()`. A non-`completed` stop reason (aborted/error/max-tokens/refusal) maps to an `isError` tool result rather than returning partial output as success.
|
||||
|
||||
Background / poll collection is deferred (see the [RFC](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md)); this cut blocks the parent turn until the child finishes.
|
||||
44
packages/subagent/tool-subagent/package.json
Normal file
44
packages/subagent/tool-subagent/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-subagent",
|
||||
"description": "Model-facing subagent delegation tool over the ctx.subagents seam",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-mock": "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"
|
||||
}
|
||||
}
|
||||
161
packages/subagent/tool-subagent/src/index.ts
Normal file
161
packages/subagent/tool-subagent/src/index.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* The model-facing `subagent` tool: delegate a task to a child agent and return
|
||||
* its final output. Pure schema + lifecycle shaping — every transport concern
|
||||
* lives behind the `ctx.subagents` provider registry
|
||||
* (`@deepseek-ai/dsh-subagent`), so an in-process, ACP, or future A2A backend
|
||||
* swaps in without touching what the model sees.
|
||||
*
|
||||
* Provider selection is config, not model-facing: this plugin is bound to
|
||||
* EXACTLY ONE provider name (`Config.provider`). To expose more than one
|
||||
* transport, load the plugin more than once, each bound to a different provider
|
||||
* — there is no provider/type parameter in the model-facing schema. The model
|
||||
* sees only `{ description, prompt }`.
|
||||
*
|
||||
* Collection is SYNCHRONOUS this cut: `execute` starts a run and awaits
|
||||
* `run.result` inside a `try/finally` that always disposes the run, so the
|
||||
* owned child agent/session is torn down on every path (success, error, abort)
|
||||
* and never leaks as a live idle child. A non-`completed` stop reason maps to an
|
||||
* `isError` tool result (by throwing) rather than returning partial output as
|
||||
* success.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-subagent
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { AgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
|
||||
export const name = 'tool-subagent'
|
||||
export const inject = ['tools', 'subagents']
|
||||
|
||||
/** Config: which registered provider this tool delegates to, plus child defaults. */
|
||||
export interface Config {
|
||||
/** The `ctx.subagents` provider name to start runs on (e.g. `spawn`, `acp`). */
|
||||
provider: string
|
||||
/**
|
||||
* The model-facing tool name to register (default `subagent`). To expose more
|
||||
* than one transport, load this plugin once per provider — each load MUST set
|
||||
* a distinct `toolName` (the tool registry rejects a duplicate name), e.g.
|
||||
* `{ provider: 'spawn', toolName: 'subagent' }` and
|
||||
* `{ provider: 'acp', toolName: 'subagent_acp' }`.
|
||||
*/
|
||||
toolName?: string
|
||||
/**
|
||||
* Default per-child agent options (model, system prompt) applied to every
|
||||
* spawned child. Omitted fields fall back to the child loop's own defaults.
|
||||
*/
|
||||
agentOptions?: AgentOptions
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
provider: z.string().required(),
|
||||
toolName: z.string().default('subagent'),
|
||||
agentOptions: z.object({
|
||||
model: z.string(),
|
||||
systemPrompt: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
/**
|
||||
* Flatten a child's final output blocks to text for the tool result. The child
|
||||
* may return non-text blocks; this cut surfaces the text content (the common
|
||||
* case) and drops the rest, which is acceptable for a synchronous summary —
|
||||
* the structured path (`outputSchema`) is the channel for non-text results.
|
||||
*/
|
||||
function outputText(blocks: ContentBlock[]): string {
|
||||
return blocks
|
||||
.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')
|
||||
.map(b => b.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
/** A non-`completed` stop reason means the child did not finish cleanly. */
|
||||
function stopReasonError(result: SubagentResult): string | undefined {
|
||||
switch (result.stopReason) {
|
||||
case 'completed':
|
||||
return undefined
|
||||
case 'aborted':
|
||||
return 'subagent run was cancelled'
|
||||
case 'error':
|
||||
return 'subagent run failed'
|
||||
case 'max-tokens':
|
||||
return 'subagent run hit its token limit before finishing'
|
||||
case 'refusal':
|
||||
return 'subagent declined the task'
|
||||
// Merge-extensible union: a backend may add stop reasons. Treat an unknown
|
||||
// terminal reason as a failure rather than reporting partial output as success.
|
||||
default:
|
||||
return `subagent run ended abnormally (${String(result.stopReason)})`
|
||||
}
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
ctx.tools.register(defineTool({
|
||||
name: config.toolName ?? 'subagent',
|
||||
description:
|
||||
'Delegate a self-contained task to a subagent (a separate agent that works in its own context) '
|
||||
+ 'and return its final result. Use this to offload focused, independent work — research, a scoped '
|
||||
+ 'implementation, an analysis — so it does not consume this conversation\'s context. The subagent '
|
||||
+ 'runs to completion and you receive only its final answer, not its intermediate steps. Give it a '
|
||||
+ 'complete, standalone prompt: it does not see this conversation.',
|
||||
parameters: {
|
||||
description: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'A short (3-5 word) description of the delegated task, for display.',
|
||||
},
|
||||
prompt: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The complete, self-contained task for the subagent. It does not share this '
|
||||
+ 'conversation\'s context, so include everything it needs.',
|
||||
},
|
||||
},
|
||||
async execute(args, exec): Promise<ContentBlock[]> {
|
||||
const parent = exec.agent
|
||||
if (!parent) {
|
||||
// The loop sets `exec.agent` for every model-driven call; its absence
|
||||
// means a non-agent caller invoked the tool directly, which has no
|
||||
// parent to attribute the child to. Fail loud rather than guess.
|
||||
throw new Error('subagent tool requires a calling agent (exec.agent was undefined)')
|
||||
}
|
||||
|
||||
const request: SubagentStartRequest = {
|
||||
prompt: [{ type: 'text', text: args.prompt }],
|
||||
parent,
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
...config.agentOptions ? { agentOptions: config.agentOptions } : {},
|
||||
}
|
||||
|
||||
const run: SubagentRun = ctx.subagents.start(config.provider, request)
|
||||
|
||||
// Bridge the tool's abort signal to the run: if the parent step is
|
||||
// aborted while the child is in flight, cancel the child too.
|
||||
const onAbort = (): void => { run.cancel('parent step aborted') }
|
||||
exec.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
// `addEventListener` does NOT fire for a signal already aborted before this
|
||||
// line, so a step cancelled before the tool ran would never reach the
|
||||
// child. Cancel explicitly in that case — the bridge must honor an
|
||||
// already-aborted signal, not lean on each provider re-checking it.
|
||||
if (exec.signal?.aborted) run.cancel('parent step aborted')
|
||||
|
||||
try {
|
||||
const result = await run.result
|
||||
const error = stopReasonError(result)
|
||||
if (error !== undefined) {
|
||||
// Map a non-clean finish to an isError result (the registry turns a
|
||||
// throw into an isError). Report the reason, not partial output.
|
||||
throw new Error(error)
|
||||
}
|
||||
return [{ type: 'text', text: outputText(result.output) }]
|
||||
} finally {
|
||||
exec.signal?.removeEventListener('abort', onAbort)
|
||||
// Always reach child quiescence — never leak a live idle child/session.
|
||||
await run.dispose()
|
||||
}
|
||||
},
|
||||
}))
|
||||
}
|
||||
357
packages/subagent/tool-subagent/tests/tool-subagent.spec.ts
Normal file
357
packages/subagent/tool-subagent/tests/tool-subagent.spec.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import * as mock from '@deepseek-ai/dsh-subagent-mock'
|
||||
import * as tool from '../src/index.ts'
|
||||
|
||||
/**
|
||||
* Drives the REAL plugin body: mounts `dsh-tool-subagent` on a real
|
||||
* `ToolRegistry` + `SubagentService`, with the real `dsh-subagent-mock` as the
|
||||
* backend, and invokes the registered `subagent` tool through
|
||||
* `ctx.tools.execute`. The mock is the genuine collaborator (we mock only the
|
||||
* "child agent", the expensive/non-deterministic boundary) — everything
|
||||
* downstream of the tool is the shipping code path.
|
||||
*/
|
||||
|
||||
/** A minimal parent Agent — the tool reads `agent.id` for `parent`. */
|
||||
function fakeAgent(id = 'parent-1'): Agent {
|
||||
return { id: AgentId(id) } as unknown as Agent
|
||||
}
|
||||
|
||||
async function setup(toolConfig: tool.Config, mockConfig: Partial<mock.Config> = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(mock, { name: 'mock', ...mockConfig })
|
||||
await ctx.plugin(tool, toolConfig)
|
||||
return ctx
|
||||
}
|
||||
|
||||
let callCounter = 0
|
||||
function callSubagent(ctx: Context, args: unknown, over: { agent?: Agent | undefined; signal?: AbortSignal } = {}) {
|
||||
// Distinguish "no override" (use a default agent) from an explicit
|
||||
// `{ agent: undefined }` (test the no-agent path). Under
|
||||
// exactOptionalPropertyTypes the key is omitted rather than set to undefined.
|
||||
const agent = 'agent' in over ? over.agent : fakeAgent()
|
||||
return ctx.tools.execute({
|
||||
callId: CallId(`call-${++callCounter}`),
|
||||
name: 'subagent',
|
||||
arguments: args,
|
||||
...agent ? { agent } : {},
|
||||
...over.signal ? { signal: over.signal } : {},
|
||||
})
|
||||
}
|
||||
|
||||
function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
}
|
||||
|
||||
describe('dsh-tool-subagent', () => {
|
||||
it('registers a `subagent` tool that delegates to the configured provider and returns its output', async () => {
|
||||
const ctx = await setup({ provider: 'mock' }, { reply: 'child says hi' })
|
||||
const result = await callSubagent(ctx, { description: 'do a thing', prompt: 'go research X' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toBe('child says hi')
|
||||
})
|
||||
|
||||
it('exposes only description + prompt to the model (no provider/type parameter)', async () => {
|
||||
const ctx = await setup({ provider: 'mock' })
|
||||
const schema = ctx.tools.schemas().find(s => s.name === 'subagent')
|
||||
expect(schema).toBeDefined()
|
||||
const props = (schema!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
|
||||
expect(Object.keys(props).sort()).toEqual(['description', 'prompt'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ stopReason: 'aborted' as const, fragment: 'cancelled' },
|
||||
{ stopReason: 'error' as const, fragment: 'failed' },
|
||||
{ stopReason: 'max-tokens' as const, fragment: 'token limit' },
|
||||
{ stopReason: 'refusal' as const, fragment: 'declined' },
|
||||
])('maps stop reason $stopReason to an isError result (not partial success)', async ({ stopReason, fragment }) => {
|
||||
const ctx = await setup({ provider: 'mock' }, { stopReason })
|
||||
const result = await callSubagent(ctx, { description: 'd', prompt: 'p' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain(fragment)
|
||||
})
|
||||
|
||||
it('registers under a configurable toolName so multiple providers can coexist', async () => {
|
||||
// The defining multi-provider use case: two loads, two distinct tool names,
|
||||
// each bound to a different provider — the tool registry rejects duplicate
|
||||
// names, so a configurable name is what makes this work.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(mock, { name: 'spawn', reply: 'from spawn' })
|
||||
await ctx.plugin(mock, { name: 'acp', reply: 'from acp' })
|
||||
await ctx.plugin(tool, { provider: 'spawn', toolName: 'subagent' })
|
||||
await ctx.plugin(tool, { provider: 'acp', toolName: 'subagent_acp' })
|
||||
|
||||
const names = ctx.tools.schemas().map(s => s.name).filter(n => n.startsWith('subagent')).sort()
|
||||
expect(names).toEqual(['subagent', 'subagent_acp'])
|
||||
|
||||
const viaSpawn = await ctx.tools.execute({ callId: CallId('c-spawn'), name: 'subagent', arguments: { description: 'd', prompt: 'p' }, agent: fakeAgent() })
|
||||
const viaAcp = await ctx.tools.execute({ callId: CallId('c-acp'), name: 'subagent_acp', arguments: { description: 'd', prompt: 'p' }, agent: fakeAgent() })
|
||||
expect(text(viaSpawn)).toBe('from spawn')
|
||||
expect(text(viaAcp)).toBe('from acp')
|
||||
})
|
||||
|
||||
it('treats an unknown (plugin-added) stop reason as an isError result', async () => {
|
||||
// SubagentStopReason is merge-extensible; the tool's stopReasonError default
|
||||
// arm must treat an unrecognized terminal reason as a failure, not success.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'weird',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
start: () => ({
|
||||
id: AgentId('weird-child'),
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'partial' }], stopReason: 'frobnicated' as never }),
|
||||
cancel() {},
|
||||
dispose: async () => {},
|
||||
}),
|
||||
})
|
||||
await ctx.plugin(tool, { provider: 'weird' })
|
||||
|
||||
const result = await callSubagent(ctx, { description: 'd', prompt: 'p' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('abnormally')
|
||||
})
|
||||
|
||||
it('forwards configured agentOptions into the start request', async () => {
|
||||
// Cover the `config.agentOptions ? … : {}` spread: a provider that captures
|
||||
// the request lets us assert the agentOptions reached it.
|
||||
let seen: { agentOptions?: { model?: string } } | undefined
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'capture',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
start: (request) => {
|
||||
seen = request
|
||||
return {
|
||||
id: AgentId('capture-child'),
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
cancel() {},
|
||||
dispose: async () => {},
|
||||
}
|
||||
},
|
||||
})
|
||||
await ctx.plugin(tool, { provider: 'capture', agentOptions: { model: 'child-model', systemPrompt: 'be terse' } })
|
||||
|
||||
await callSubagent(ctx, { description: 'd', prompt: 'p' })
|
||||
expect(seen?.agentOptions).toEqual({ model: 'child-model', systemPrompt: 'be terse' })
|
||||
})
|
||||
|
||||
it('defaults toolName and omits agentOptions when apply() is called directly (schema bypass)', async () => {
|
||||
// `ctx.plugin` validates+defaults config first (toolName→'subagent', the
|
||||
// agentOptions object→{}), so the runtime `?? 'subagent'` fallback and the
|
||||
// no-agentOptions branch are only reachable via a direct apply() that
|
||||
// bypasses schemastery — the same pattern acp-agent uses for its defaults.
|
||||
let seen: { agentOptions?: unknown } | undefined
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'bare',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
start: (request) => {
|
||||
seen = request
|
||||
return {
|
||||
id: AgentId('bare-child'),
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
cancel() {},
|
||||
dispose: async () => {},
|
||||
}
|
||||
},
|
||||
})
|
||||
// Direct apply with only `provider` — no toolName, no agentOptions.
|
||||
tool.apply(ctx, { provider: 'bare' })
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
|
||||
expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(true)
|
||||
await callSubagent(ctx, { description: 'd', prompt: 'p' })
|
||||
expect(seen?.agentOptions).toBeUndefined()
|
||||
})
|
||||
|
||||
it('fails loud when invoked without a calling agent', async () => {
|
||||
const ctx = await setup({ provider: 'mock' })
|
||||
const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }, { agent: undefined })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('requires a calling agent')
|
||||
})
|
||||
|
||||
it('surfaces an UNSUPPORTED_CAPABILITY rejection as an isError result is NOT applicable here '
|
||||
+ '(the tool requests no capabilities) — a missing provider IS surfaced', async () => {
|
||||
// Bind the tool to a provider name that is not registered: the service throws
|
||||
// NO_PROVIDER, the registry turns it into an isError result.
|
||||
const ctx = await setup({ provider: 'does-not-exist' })
|
||||
const result = await callSubagent(ctx, { description: 'd', prompt: 'p' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('no subagent provider')
|
||||
})
|
||||
|
||||
it('disposes the run on the success path (no leaked child)', async () => {
|
||||
// Spy on the provider's run.dispose via a wrapping provider registered
|
||||
// directly on the service, then point the tool at it.
|
||||
const disposed = vi.fn()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'spy',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
start: () => ({
|
||||
id: AgentId('spy-child'),
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
cancel() {},
|
||||
dispose: async () => void disposed(),
|
||||
}),
|
||||
})
|
||||
await ctx.plugin(tool, { provider: 'spy' })
|
||||
|
||||
await callSubagent(ctx, { description: 'd', prompt: 'p' })
|
||||
expect(disposed).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('disposes the run on the error path too', async () => {
|
||||
const disposed = vi.fn()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'spy',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
start: () => ({
|
||||
id: AgentId('spy-child'),
|
||||
result: Promise.resolve({ output: [], stopReason: 'error' as const }),
|
||||
cancel() {},
|
||||
dispose: async () => void disposed(),
|
||||
}),
|
||||
})
|
||||
await ctx.plugin(tool, { provider: 'spy' })
|
||||
|
||||
const result = await callSubagent(ctx, { description: 'd', prompt: 'p' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(disposed).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('bridges the tool abort signal to run.cancel()', async () => {
|
||||
const cancelled = vi.fn()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'spy',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
start: () => {
|
||||
let resolveResult: (r: { output: never[]; stopReason: 'aborted' }) => void
|
||||
const result = new Promise<{ output: never[]; stopReason: 'aborted' }>((res) => { resolveResult = res })
|
||||
return {
|
||||
id: AgentId('spy-child'),
|
||||
result,
|
||||
cancel: () => {
|
||||
cancelled()
|
||||
resolveResult({ output: [], stopReason: 'aborted' })
|
||||
},
|
||||
dispose: async () => {},
|
||||
}
|
||||
},
|
||||
})
|
||||
await ctx.plugin(tool, { provider: 'spy' })
|
||||
|
||||
const controller = new AbortController()
|
||||
const pending = callSubagent(ctx, { description: 'd', prompt: 'p' }, { signal: controller.signal })
|
||||
controller.abort()
|
||||
const result = await pending
|
||||
expect(cancelled).toHaveBeenCalledTimes(1)
|
||||
expect(result.isError).toBe(true)
|
||||
})
|
||||
|
||||
it('cancels the run when the tool signal is ALREADY aborted before execute (no missed abort)', async () => {
|
||||
// `addEventListener('abort')` does not fire for a signal already aborted
|
||||
// before the listener is added, so a step cancelled before the tool ran
|
||||
// would never reach the child unless the bridge re-checks `signal.aborted`.
|
||||
// A provider that leans only on the abort EVENT (this spy never inspects
|
||||
// request.signal) proves the bridge itself must cancel.
|
||||
const cancelled = vi.fn()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'spy',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
start: () => {
|
||||
let resolveResult: (r: { output: never[]; stopReason: 'aborted' }) => void
|
||||
const result = new Promise<{ output: never[]; stopReason: 'aborted' }>((res) => { resolveResult = res })
|
||||
return {
|
||||
id: AgentId('spy-child'),
|
||||
result,
|
||||
cancel: () => {
|
||||
cancelled()
|
||||
resolveResult({ output: [], stopReason: 'aborted' })
|
||||
},
|
||||
dispose: async () => {},
|
||||
}
|
||||
},
|
||||
})
|
||||
await ctx.plugin(tool, { provider: 'spy' })
|
||||
|
||||
const controller = new AbortController()
|
||||
controller.abort() // already aborted BEFORE the tool runs
|
||||
const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }, { signal: controller.signal })
|
||||
expect(cancelled).toHaveBeenCalledTimes(1)
|
||||
expect(result.isError).toBe(true)
|
||||
})
|
||||
|
||||
it('tools depend on the service: no `subagent` tool without ctx.subagents', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
// No SubagentService mounted. The tool injects ['tools','subagents'] so its
|
||||
// apply never runs; the tool is absent rather than half-registered.
|
||||
let booted = true
|
||||
try {
|
||||
await ctx.plugin(tool, { provider: 'mock' })
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
} catch {
|
||||
booted = false
|
||||
}
|
||||
// Either it never booted, or it booted but registered no tool.
|
||||
const present = ctx.get('tools')?.schemas().some(s => s.name === 'subagent') ?? false
|
||||
expect(booted && present).toBe(false)
|
||||
})
|
||||
|
||||
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/Config/apply', () => {
|
||||
// Postmortem 0001 guard: this plugin HAS `inject = ['tools','subagents']`, so
|
||||
// a stray `export default apply` would collapse the module via
|
||||
// `unwrapExports` (`exports.default ?? exports`), DROP `inject`, and crash at
|
||||
// load with "cannot get property … without inject". Guard the shape directly.
|
||||
expect('default' in tool).toBe(false)
|
||||
expect(tool.name).toBe('tool-subagent')
|
||||
expect(tool.inject).toEqual(['tools', 'subagents'])
|
||||
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(tool) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(tool)
|
||||
expect(unwrapped.name).toBe('tool-subagent')
|
||||
expect(unwrapped.inject).toEqual(['tools', 'subagents'])
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
})
|
||||
})
|
||||
33
packages/subagent/tool-subagent/tsconfig.json
Normal file
33
packages/subagent/tool-subagent/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../subagent"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
|
||||
@@ -10,26 +10,35 @@ The fixture IS the persisted session log (`<scenario>/session.jsonl`). Its `assi
|
||||
|
||||
Two failure modes are not reconstructable from `assistant/chunk` alone — a pure throw before any chunk (e.g. an HTTP 401, where the log holds only a `turn/end {error}` and no chunks) and a cancel/hang (timing, not chunk content). A scenario that needs those supplies an optional sidecar (`<scenario>/replay.override.json`: a `ReplayEntry[]`) that REPLACES the derived script.
|
||||
|
||||
## Nested agents: per-session keying
|
||||
|
||||
A scenario where a parent agent delegates to in-process subagents records more than one log: the parent (`session.jsonl`) plus one per child (`session.1.jsonl`, …). Each agent runs as its own `Session` on the same context, so replay must serve each one its own script.
|
||||
|
||||
Replay keys every call by its calling session id (`GenerateOptions.sessionId`, stamped by the agent loop). Live session ids are freshly random each run and never equal the recorded ones, so a live session binds to a recorded script by **first-call order**: scripts are ordered by header `createdAt` (parent first — it streams before it can delegate), and the first live session to make any call claims the first script, the next new session the next, and so on. Each session then advances its own cursor. A call with no `sessionId` is one anonymous session bound to the primary script, so single-session scenarios behave exactly as before. More distinct live sessions than recorded scripts fails loud.
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Type | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `file` | string | `$DSH_SNAPSHOT_FILE` | Path to the per-scenario `session.jsonl` fixture. Required (config or env). |
|
||||
| `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | Optional path to a `ReplayEntry[]` sidecar that replaces the derived script. |
|
||||
| `file` | string | `$DSH_SNAPSHOT_FILE` | Path to the primary (parent) `session.jsonl` fixture. Required (config or env). |
|
||||
| `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | Optional path to a `ReplayEntry[]` sidecar that replaces the PRIMARY session's derived script. |
|
||||
| `childFiles` | string[] | `$DSH_SNAPSHOT_CHILD_FILES` (path-delimited) | Recorded subagent child-session logs for a nested scenario; empty for a single-session scenario. |
|
||||
|
||||
```yaml
|
||||
- id: llm-replay
|
||||
name: '@deepseek-ai/dsh-llm-replay'
|
||||
# file/overrideFile default to $DSH_SNAPSHOT_FILE / $DSH_SNAPSHOT_OVERRIDE,
|
||||
# set by the snapshot harness per scenario.
|
||||
# file/overrideFile/childFiles default to $DSH_SNAPSHOT_FILE /
|
||||
# $DSH_SNAPSHOT_OVERRIDE / $DSH_SNAPSHOT_CHILD_FILES, set by the snapshot
|
||||
# harness per scenario.
|
||||
```
|
||||
|
||||
## Exports
|
||||
|
||||
- `installLlmReplay(ctx, config)` — install the `llm/stream` listener; returns the disposer (HMR safety). Use this in tests to drive replay without the Loader or env vars.
|
||||
- `loadReplayScript(config)` — resolve the `ReplayEntry[]` for a scenario (sidecar override if present, else derived from the JSONL; fail-loud if the fixture is missing).
|
||||
- `deriveReplayScript(events)` / `parseSessionLog(text)` — the pure helpers that turn a recorded session log into a script. A derived group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar.
|
||||
- Types `ReplayEntry` / `ReplayConfig` / `Config`.
|
||||
- `loadSessionScripts(config)` — resolve the ordered `SessionScript[]` (primary + children) for a scenario, ready to bind to live sessions in first-call order.
|
||||
- `loadReplayScript(config)` — resolve the `ReplayEntry[]` for the PRIMARY session only (sidecar override if present, else derived from the JSONL; fail-loud if the fixture is missing).
|
||||
- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` — the pure helpers that turn a recorded session log into a script and read its header `id`/`createdAt`. A derived group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar.
|
||||
- Types `ReplayEntry` / `SessionScript` / `ReplayConfig` / `Config`.
|
||||
|
||||
## Plugin export shape
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -14,6 +14,15 @@
|
||||
* therefore "run the real agent once and harvest the `.jsonl`", done by the
|
||||
* snapshot harness — this plugin does not record.
|
||||
*
|
||||
* A NESTED-agent scenario records more than one log: the parent plus one per
|
||||
* in-process subagent (each subagent runs as its own {@link Session} on the same
|
||||
* context). Replay loads them all ({@link loadSessionScripts}), derives a script
|
||||
* per recorded session, and keys each live call by its calling session id
|
||||
* (`GenerateOptions.sessionId`, stamped by the loop). Live session ids are fresh
|
||||
* random values, so a live session binds to a recorded script by FIRST-CALL
|
||||
* order (parent first — it streams before it delegates); see
|
||||
* {@link installLlmReplay}.
|
||||
*
|
||||
* Two failure modes are NOT reconstructable from `assistant/chunk` alone — a
|
||||
* pure throw before any chunk (e.g. an HTTP 401: the log holds only a
|
||||
* `turn/end {error}`, no chunks) and a cancel/hang (timing, not chunk content).
|
||||
@@ -36,6 +45,7 @@
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { delimiter as pathDelimiter } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
@@ -65,14 +75,49 @@ export type ReplayEntry =
|
||||
|
||||
/** Resolved plugin configuration. */
|
||||
export interface ReplayConfig {
|
||||
/** Path to the per-scenario `session.jsonl` fixture (the recorded log). */
|
||||
/**
|
||||
* Path to the PRIMARY (parent) `session.jsonl` fixture. For a single-session
|
||||
* scenario this is the only log; for a nested-agent scenario it is the parent,
|
||||
* and the child logs ride in {@link childFiles}.
|
||||
*/
|
||||
file: string
|
||||
/**
|
||||
* Optional path to a `ReplayEntry[]` sidecar that REPLACES the derived
|
||||
* script. Used by the two scenarios not expressible as `assistant/chunk`
|
||||
* (pure throw-before-chunk, cancel/hang). Absent for normal scenarios.
|
||||
* Optional `ReplayEntry[]` sidecar that REPLACES the derived script for the
|
||||
* PRIMARY session. Used by the two single-session scenarios not expressible as
|
||||
* `assistant/chunk` (pure throw-before-chunk, cancel/hang). Absent for normal
|
||||
* and nested scenarios.
|
||||
*/
|
||||
overrideFile?: string
|
||||
/**
|
||||
* Additional recorded child-session logs (a nested-agent scenario's subagent
|
||||
* sessions). Each is derived independently; the full set is ordered by
|
||||
* `createdAt` so the parent (earliest) binds to the first live session. Empty
|
||||
* for a single-session scenario.
|
||||
*/
|
||||
childFiles?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* One recorded session's replay script: the per-call entries plus the header
|
||||
* facts needed to ORDER and key it. Live session ids are freshly random at
|
||||
* replay time and never equal the recorded `id`, so the recorded id is only a
|
||||
* diagnostic; `createdAt` is the load-bearing field — scripts are ordered by it
|
||||
* (a parent is created before its children) and each newly-seen live session is
|
||||
* bound to the next script in that order (= first-call order in the synchronous
|
||||
* nested cut, where the parent streams before it delegates).
|
||||
*/
|
||||
export interface SessionScript {
|
||||
/** The recorded session id (diagnostics only — the live id differs). */
|
||||
recordedId: string
|
||||
/** Session creation time; the deterministic ordering key (parent < child). */
|
||||
createdAt: number
|
||||
/** The per-`stream()`-call replay entries, in recorded call order. */
|
||||
entries: ReplayEntry[]
|
||||
/**
|
||||
* Whether this is the PRIMARY (parent) session. Breaks a `createdAt` tie in
|
||||
* favor of the parent, which always issues the first model call.
|
||||
*/
|
||||
primary: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,6 +138,26 @@ export function parseSessionLog(text: string): SessionEvent[] {
|
||||
return events
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the identifying facts off a session log's header line (line 0): the
|
||||
* recorded session `id` (diagnostics), `createdAt` (the deterministic ordering
|
||||
* key that binds a recorded script to a live session — see
|
||||
* {@link SessionScript}), and `seedLength` (the seed boundary — how many leading
|
||||
* events were INHERITED via a fork seed rather than produced by this session's
|
||||
* own model calls; absent ⇒ 0). A header missing a field falls back to a stable
|
||||
* default (`''` / `0` / `0`) rather than throwing: a no-model fixture is
|
||||
* header-only and still orders fine as the single (primary) script.
|
||||
*/
|
||||
export function parseSessionHeader(text: string): { id: string; createdAt: number; seedLength: number } {
|
||||
const firstLine = text.split('\n').find(line => line.trim().length > 0) ?? '{}'
|
||||
const parsed = JSON.parse(firstLine) as { id?: unknown; createdAt?: unknown; seedLength?: unknown }
|
||||
return {
|
||||
id: typeof parsed.id === 'string' ? parsed.id : '',
|
||||
createdAt: typeof parsed.createdAt === 'number' ? parsed.createdAt : 0,
|
||||
seedLength: typeof parsed.seedLength === 'number' ? parsed.seedLength : 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct the per-`stream()` replay script from a recorded session log.
|
||||
*
|
||||
@@ -144,11 +209,11 @@ export function deriveReplayScript(events: SessionEvent[]): ReplayEntry[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the replay script for a scenario: the sidecar override if present,
|
||||
* otherwise the script derived from the recorded session JSONL. Fail-loud if
|
||||
* the JSONL fixture is missing (the scenario was never recorded) — never
|
||||
* silently returns an empty script, so a coverage hole can't masquerade as a
|
||||
* passing replay.
|
||||
* Build the replay script for the PRIMARY session: the sidecar override if
|
||||
* present, otherwise the script derived from the recorded session JSONL.
|
||||
* Fail-loud if the JSONL fixture is missing (the scenario was never recorded) —
|
||||
* never silently returns an empty script, so a coverage hole can't masquerade
|
||||
* as a passing replay.
|
||||
*/
|
||||
export function loadReplayScript(config: ReplayConfig): ReplayEntry[] {
|
||||
if (config.overrideFile !== undefined && existsSync(config.overrideFile)) {
|
||||
@@ -164,6 +229,69 @@ export function loadReplayScript(config: ReplayConfig): ReplayEntry[] {
|
||||
return deriveReplayScript(parseSessionLog(readFileSync(config.file, 'utf8')))
|
||||
}
|
||||
|
||||
/**
|
||||
* Load every recorded session's script for a scenario, ordered by `createdAt`
|
||||
* (earliest first), ready to bind to live sessions in first-call order.
|
||||
*
|
||||
* The PRIMARY session (`config.file`, with its optional `overrideFile`) is the
|
||||
* parent; each `config.childFiles` entry is a recorded subagent session. A
|
||||
* single-session scenario has no `childFiles`, so this returns one script and
|
||||
* behaves exactly like the old single-cursor replay. The primary always sorts
|
||||
* first when ties occur (a sub-millisecond parent/child `createdAt` collision):
|
||||
* the parent issues the FIRST model call (it must stream before it can delegate
|
||||
* in the synchronous nested cut), so binding it to the first live session is
|
||||
* correct regardless of a timestamp tie.
|
||||
*/
|
||||
export function loadSessionScripts(config: ReplayConfig): SessionScript[] {
|
||||
const primaryEntries = loadReplayScript(config)
|
||||
// The override path replaces the derived script but carries no header; read
|
||||
// the header off the JSONL when it exists, else use a stable default so an
|
||||
// override-only fixture (header-less) still orders first as the primary.
|
||||
const primaryHeader = existsSync(config.file)
|
||||
? parseSessionHeader(readFileSync(config.file, 'utf8'))
|
||||
: { id: '', createdAt: 0 }
|
||||
const primary: SessionScript = {
|
||||
recordedId: primaryHeader.id, createdAt: primaryHeader.createdAt, entries: primaryEntries, primary: true,
|
||||
}
|
||||
const children: SessionScript[] = []
|
||||
for (const childFile of config.childFiles ?? []) {
|
||||
if (!existsSync(childFile)) {
|
||||
throw new Error(`llm-replay: child fixture not found: ${childFile} — re-record the scenario`)
|
||||
}
|
||||
const text = readFileSync(childFile, 'utf8')
|
||||
const header = parseSessionHeader(text)
|
||||
// Derive the child's script from its OWN events only — events AT OR AFTER
|
||||
// the seed boundary. A FORK child's log begins with the seeded parent prefix
|
||||
// (the parent's events, including its `assistant/chunk`s); replaying those as
|
||||
// the child's model calls would feed the child the PARENT's recorded
|
||||
// responses. `seedLength` is 0 for a fresh (spawn) child, so this is a no-op
|
||||
// there.
|
||||
const ownEvents = parseSessionLog(text).slice(header.seedLength)
|
||||
children.push({
|
||||
recordedId: header.id,
|
||||
createdAt: header.createdAt,
|
||||
entries: deriveReplayScript(ownEvents),
|
||||
primary: false,
|
||||
})
|
||||
}
|
||||
// The primary (parent) always binds first — it issues the first model call,
|
||||
// because it must run a turn before it can delegate. Children follow in
|
||||
// createdAt order. In the current synchronous cut sibling children are created
|
||||
// STRICTLY SEQUENTIALLY — the subagent tool awaits one child's result and
|
||||
// disposes it before the parent's next tool call can start the next — so their
|
||||
// createdAt values are strictly ordered and match first-call order exactly.
|
||||
// The recordedId tiebreak only makes a degenerate same-millisecond collision
|
||||
// (unreachable in this cut) deterministic; it does NOT recover first-call
|
||||
// order, so it is arbitrary if such a tie ever occurs.
|
||||
// XXX(concurrent-subagents): a future cut that runs siblings concurrently or
|
||||
// backgrounded could create two children in the same millisecond, where this
|
||||
// createdAt+id order may diverge from first-call order. That cut must thread a
|
||||
// real first-call ordinal (the order live sessions first stream) instead of
|
||||
// leaning on createdAt — see the per-session-replay RFC.
|
||||
children.sort((a, b) => a.createdAt - b.createdAt || a.recordedId.localeCompare(b.recordedId))
|
||||
return [primary, ...children]
|
||||
}
|
||||
|
||||
/** Yield a recorded stream back, honoring abort like a real adapter. */
|
||||
async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined): AsyncIterable<StreamChunk> {
|
||||
switch (entry.kind) {
|
||||
@@ -206,22 +334,70 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined)
|
||||
* disposer (so a fiber dispose removes it — HMR safety). Exported separately
|
||||
* from {@link apply} so unit tests can drive it without the Loader or env vars.
|
||||
*
|
||||
* Replay is POSITIONAL: the Nth `stream()` call serves the Nth script entry.
|
||||
* This is deterministic only with at most one model stream in flight at a time;
|
||||
* 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.
|
||||
* Replay is PER-SESSION POSITIONAL: each recorded session has its own script
|
||||
* (parent + any subagent children, loaded by {@link loadSessionScripts} ordered
|
||||
* by `createdAt`), and the Nth `stream()` call FROM A GIVEN SESSION serves that
|
||||
* session's Nth entry. The calling session is read off `options.sessionId` (the
|
||||
* agent loop stamps it from `agent.session.id`).
|
||||
*
|
||||
* Live session ids are freshly random and never equal the recorded ones, so a
|
||||
* live session binds to a recorded script by FIRST-CALL ORDER: the first live
|
||||
* session to make any call takes the first ordered script (the parent — earliest
|
||||
* `createdAt`, and the first to stream because it must run before it delegates),
|
||||
* the next new live session takes the next script, and so on. This keys by WHO
|
||||
* calls rather than global call order, so it stays correct even if subagents
|
||||
* ever run concurrently/backgrounded (a global cursor would interleave them).
|
||||
*
|
||||
* A call with no `sessionId` (a direct unit-test `ctx.llm.stream` that omits it)
|
||||
* is treated as one anonymous session — it binds to the first script, so the
|
||||
* single-session path behaves exactly as the old global cursor did.
|
||||
*
|
||||
* Each per-session cursor advances synchronously at listener-invocation time
|
||||
* (not lazily inside the generator) so call ORDER within a session, not
|
||||
* iteration order, fixes the mapping.
|
||||
*/
|
||||
export function installLlmReplay(ctx: Context, config: ReplayConfig): () => void {
|
||||
const entries = loadReplayScript(config)
|
||||
let cursor = 0
|
||||
const scripts = loadSessionScripts(config)
|
||||
// Live-session → its bound script + cursor. A new live session id claims the
|
||||
// next not-yet-bound script (scripts are in bind order); `nextScript` is the
|
||||
// index of the next unclaimed one.
|
||||
const bound = new Map<string, { entries: ReplayEntry[]; cursor: number }>()
|
||||
let nextScript = 0
|
||||
const ANON = '\0anon\0' // the key for a call that carries no sessionId
|
||||
return ctx.on('llm/stream', (options: GenerateOptions, _next) => {
|
||||
const index = cursor++
|
||||
const entry: ReplayEntry | undefined = entries[index]
|
||||
const key = options.sessionId ?? ANON
|
||||
let state = bound.get(key)
|
||||
let unrecorded = false
|
||||
if (state === undefined) {
|
||||
const script = scripts[nextScript]
|
||||
if (script === undefined) {
|
||||
// More distinct live sessions made calls than the scenario recorded —
|
||||
// an unrecorded subagent appeared. Defer the throw into the returned
|
||||
// generator (the listener must return an AsyncIterable, not throw).
|
||||
unrecorded = true
|
||||
state = { entries: [], cursor: 0 }
|
||||
} else {
|
||||
nextScript++
|
||||
state = { entries: script.entries, cursor: 0 }
|
||||
bound.set(key, state)
|
||||
}
|
||||
}
|
||||
const boundState = state
|
||||
const seenSessions = nextScript
|
||||
const totalScripts = scripts.length
|
||||
const index = boundState.cursor++
|
||||
const entry: ReplayEntry | undefined = boundState.entries[index]
|
||||
return (async function* () {
|
||||
if (unrecorded) {
|
||||
throw new Error(
|
||||
`llm-replay: a model call arrived from an unrecorded session (#${seenSessions + 1}); `
|
||||
+ `the scenario recorded only ${totalScripts} session(s) — re-record it`,
|
||||
)
|
||||
}
|
||||
if (entry === undefined) {
|
||||
throw new Error(
|
||||
`llm-replay: script exhausted — requested model call #${index + 1} but the fixture has only ${entries.length}; re-record the scenario`,
|
||||
`llm-replay: script exhausted — session requested model call #${index + 1} `
|
||||
+ `but its script has only ${boundState.entries.length}; re-record the scenario`,
|
||||
)
|
||||
}
|
||||
yield* replayEntry(entry, options.signal)
|
||||
@@ -237,6 +413,12 @@ export interface Config {
|
||||
file?: string
|
||||
/** Override the sidecar path; defaults to `$DSH_SNAPSHOT_OVERRIDE`. */
|
||||
overrideFile?: string
|
||||
/**
|
||||
* Override the child-log paths; defaults to `$DSH_SNAPSHOT_CHILD_FILES` (a
|
||||
* path-separator-delimited list). Each is a recorded subagent session log for
|
||||
* a nested-agent scenario; absent/empty for a single-session scenario.
|
||||
*/
|
||||
childFiles?: string[]
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
@@ -245,5 +427,12 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
throw new Error('llm-replay: a fixture path is required (Config.file or $DSH_SNAPSHOT_FILE)')
|
||||
}
|
||||
const overrideFile = config.overrideFile ?? process.env.DSH_SNAPSHOT_OVERRIDE
|
||||
installLlmReplay(ctx, overrideFile === undefined || overrideFile.length === 0 ? { file } : { file, overrideFile })
|
||||
const childEnv = process.env.DSH_SNAPSHOT_CHILD_FILES
|
||||
const childFiles = config.childFiles
|
||||
?? (childEnv !== undefined && childEnv.length > 0 ? childEnv.split(pathDelimiter) : [])
|
||||
installLlmReplay(ctx, {
|
||||
file,
|
||||
...overrideFile !== undefined && overrideFile.length > 0 ? { overrideFile } : {},
|
||||
...childFiles.length > 0 ? { childFiles } : {},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,12 +7,15 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import LlmService, { GenerateOptions, LlmAdapter, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
type ReplayEntry,
|
||||
type SessionScript,
|
||||
apply,
|
||||
deriveReplayScript,
|
||||
inject,
|
||||
installLlmReplay,
|
||||
loadReplayScript,
|
||||
loadSessionScripts,
|
||||
name,
|
||||
parseSessionHeader,
|
||||
parseSessionLog,
|
||||
} from '../src/index.ts'
|
||||
|
||||
@@ -32,9 +35,15 @@ const TEXT_CHUNKS: StreamChunk[] = [
|
||||
]
|
||||
|
||||
/** Build a minimal session-JSONL string: a header line + the given events. */
|
||||
function sessionJsonl(events: SessionEvent[]): string {
|
||||
const header = JSON.stringify({ type: 'session', version: 0, id: 's1', createdAt: 0 })
|
||||
return [header, ...events.map(e => JSON.stringify(e))].join('\n') + '\n'
|
||||
function sessionJsonl(events: SessionEvent[], header?: { id?: string; createdAt?: number; seedLength?: number }): string {
|
||||
const headerLine = JSON.stringify({
|
||||
type: 'session',
|
||||
version: 0,
|
||||
id: header?.id ?? 's1',
|
||||
createdAt: header?.createdAt ?? 0,
|
||||
...header?.seedLength !== undefined ? { seedLength: header.seedLength } : {},
|
||||
})
|
||||
return [headerLine, ...events.map(e => JSON.stringify(e))].join('\n') + '\n'
|
||||
}
|
||||
|
||||
/** A SessionEvent of type assistant/chunk for (turn, step). */
|
||||
@@ -361,13 +370,219 @@ describe('installLlmReplay (through the real waterfall)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseSessionHeader', () => {
|
||||
it('reads id, createdAt, and seedLength off the header line', () => {
|
||||
expect(parseSessionHeader(sessionJsonl([], { id: 'abc', createdAt: 42 })))
|
||||
.toEqual({ id: 'abc', createdAt: 42, seedLength: 0 })
|
||||
})
|
||||
|
||||
it('reads a non-zero seedLength (a fork child header)', () => {
|
||||
expect(parseSessionHeader('{"type":"session","version":0,"id":"child","createdAt":7,"seedLength":4}\n'))
|
||||
.toEqual({ id: 'child', createdAt: 7, seedLength: 4 })
|
||||
})
|
||||
|
||||
it('falls back to id="" / createdAt=0 / seedLength=0 when the header lacks them', () => {
|
||||
expect(parseSessionHeader('{"type":"session","version":0}\n')).toEqual({ id: '', createdAt: 0, seedLength: 0 })
|
||||
})
|
||||
|
||||
it('falls back on an empty buffer (no header line)', () => {
|
||||
expect(parseSessionHeader('')).toEqual({ id: '', createdAt: 0, seedLength: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadSessionScripts', () => {
|
||||
/** Write a session log file and return its path. */
|
||||
function writeSession(filename: string, header: { id: string; createdAt: number }, calls: StreamChunk[][]): string {
|
||||
let seq = 1
|
||||
const events: SessionEvent[] = []
|
||||
calls.forEach((chunks, step) => { for (const c of chunks) events.push(chunkEvent(seq++, 1, step + 1, c)) })
|
||||
const path = join(dir, filename)
|
||||
writeFileSync(path, sessionJsonl(events, header), 'utf8')
|
||||
return path
|
||||
}
|
||||
|
||||
it('returns one primary script for a single-session scenario', () => {
|
||||
const f = writeSession('session.jsonl', { id: 'p', createdAt: 100 }, [TEXT_CHUNKS])
|
||||
const scripts: SessionScript[] = loadSessionScripts({ file: f })
|
||||
expect(scripts).toHaveLength(1)
|
||||
expect(scripts[0]).toMatchObject({ recordedId: 'p', createdAt: 100, primary: true })
|
||||
expect(scripts[0]?.entries).toEqual([{ kind: 'chunks', chunks: TEXT_CHUNKS }])
|
||||
})
|
||||
|
||||
it('orders parent + children by createdAt with the primary first on a tie', () => {
|
||||
const f = writeSession('session.jsonl', { id: 'parent', createdAt: 100 }, [TEXT_CHUNKS])
|
||||
// One child created LATER, one child sharing the parent's createdAt (tie).
|
||||
const later = writeSession('session.1.jsonl', { id: 'late', createdAt: 200 }, [TEXT_CHUNKS])
|
||||
const tie = writeSession('session.2.jsonl', { id: 'tie', createdAt: 100 }, [TEXT_CHUNKS])
|
||||
const scripts = loadSessionScripts({ file: f, childFiles: [later, tie] })
|
||||
// parent (100, primary) → tie (100, non-primary) → late (200).
|
||||
expect(scripts.map(s => s.recordedId)).toEqual(['parent', 'tie', 'late'])
|
||||
expect(scripts[0]?.primary).toBe(true)
|
||||
})
|
||||
|
||||
it('throws when a declared child fixture is missing', () => {
|
||||
const f = writeSession('session.jsonl', { id: 'p', createdAt: 1 }, [TEXT_CHUNKS])
|
||||
expect(() => loadSessionScripts({ file: f, childFiles: [join(dir, 'absent.jsonl')] }))
|
||||
.toThrow(/child fixture not found/)
|
||||
})
|
||||
|
||||
it('derives a FORK child script from its OWN events only (skips the seeded parent prefix)', () => {
|
||||
// A fork child's log begins with the seeded parent prefix — the parent's
|
||||
// events, INCLUDING its assistant/chunk events. Deriving the child script
|
||||
// from the whole log would replay the PARENT's recorded responses as the
|
||||
// child's model calls. With seedLength recorded, the child script must
|
||||
// contain only the child's OWN chunks (those after the boundary).
|
||||
const parentChunk: StreamChunk = { type: 'text-delta', index: 0, text: 'PARENT-RESPONSE' }
|
||||
const childChunks: StreamChunk[] = [{ type: 'text-delta', index: 0, text: 'CHILD-RESPONSE' }, { type: 'finish', reason: { kind: 'stop' } }]
|
||||
const f = writeSession('session.jsonl', { id: 'parent', createdAt: 100 }, [TEXT_CHUNKS])
|
||||
// The child fixture: 2 seeded parent events (a chunk + its finish) then the
|
||||
// child's own turn. seedLength = 2 marks where the inherited prefix ends.
|
||||
const childEvents: SessionEvent[] = [
|
||||
chunkEvent(0, 1, 1, parentChunk),
|
||||
chunkEvent(1, 1, 1, { type: 'finish', reason: { kind: 'stop' } }),
|
||||
chunkEvent(2, 2, 1, childChunks[0]!),
|
||||
chunkEvent(3, 2, 1, childChunks[1]!),
|
||||
]
|
||||
const childPath = join(dir, 'session.1.jsonl')
|
||||
writeFileSync(childPath, sessionJsonl(childEvents, { id: 'child', createdAt: 200, seedLength: 2 }), 'utf8')
|
||||
|
||||
const scripts = loadSessionScripts({ file: f, childFiles: [childPath] })
|
||||
// The child script is ONLY the child's own model call — the parent's seeded
|
||||
// chunk is gone.
|
||||
expect(scripts[1]?.entries).toEqual([{ kind: 'chunks', chunks: childChunks }])
|
||||
})
|
||||
|
||||
it('uses the override for the primary and still derives children', () => {
|
||||
writeFileSync(file, sessionJsonl([], { id: 'p', createdAt: 1 }), 'utf8')
|
||||
const overrideFile = join(dir, 'replay.override.json')
|
||||
const override: ReplayEntry[] = [{ kind: 'hang' }]
|
||||
writeFileSync(overrideFile, JSON.stringify(override), 'utf8')
|
||||
const child = writeSession('session.1.jsonl', { id: 'c', createdAt: 2 }, [TEXT_CHUNKS])
|
||||
const scripts = loadSessionScripts({ file, overrideFile, childFiles: [child] })
|
||||
expect(scripts[0]?.entries).toEqual(override)
|
||||
expect(scripts[1]?.entries).toEqual([{ kind: 'chunks', chunks: TEXT_CHUNKS }])
|
||||
})
|
||||
|
||||
it('defaults the primary header to id="" / createdAt=0 when only an override (no JSONL) exists', () => {
|
||||
// An override-only fixture: config.file does NOT exist, the override drives
|
||||
// the primary script, so the header default branch applies.
|
||||
const overrideFile = join(dir, 'replay.override.json')
|
||||
writeFileSync(overrideFile, JSON.stringify([{ kind: 'hang' }]), 'utf8')
|
||||
const scripts = loadSessionScripts({ file: join(dir, 'absent.jsonl'), overrideFile })
|
||||
expect(scripts).toHaveLength(1)
|
||||
expect(scripts[0]).toMatchObject({ recordedId: '', createdAt: 0, primary: true })
|
||||
})
|
||||
|
||||
it('orders two same-createdAt children deterministically after the primary', () => {
|
||||
// Two children sharing a createdAt (both non-primary): exercises the sort
|
||||
// tie-break\'s "both same primary-ness" arm and a non-primary-vs-primary arm.
|
||||
const f = writeSession('session.jsonl', { id: 'parent', createdAt: 100 }, [TEXT_CHUNKS])
|
||||
const c1 = writeSession('session.1.jsonl', { id: 'c1', createdAt: 100 }, [TEXT_CHUNKS])
|
||||
const c2 = writeSession('session.2.jsonl', { id: 'c2', createdAt: 100 }, [TEXT_CHUNKS])
|
||||
const scripts = loadSessionScripts({ file: f, childFiles: [c1, c2] })
|
||||
// Primary first (its createdAt ties the children but primary wins); the two
|
||||
// children keep a stable relative order.
|
||||
expect(scripts[0]?.recordedId).toBe('parent')
|
||||
expect(scripts.every(s => s.createdAt === 100)).toBe(true)
|
||||
expect(scripts.map(s => s.primary)).toEqual([true, false, false])
|
||||
})
|
||||
|
||||
it('keeps the primary first even when a child sorts BEFORE it in input order', () => {
|
||||
// The primary is appended first internally but the child has an EARLIER
|
||||
// createdAt — the primary must still win on the tie-break against a
|
||||
// later-but-equal child, and lose only to a genuinely earlier child via
|
||||
// createdAt (here the child is earlier, so order is child-then-primary only
|
||||
// if createdAt strictly less; equal createdAt keeps primary first).
|
||||
const f = writeSession('session.jsonl', { id: 'parent', createdAt: 100 }, [TEXT_CHUNKS])
|
||||
const earlier = writeSession('session.1.jsonl', { id: 'early', createdAt: 100 }, [TEXT_CHUNKS])
|
||||
const scripts = loadSessionScripts({ file: f, childFiles: [earlier] })
|
||||
// Equal createdAt → primary first.
|
||||
expect(scripts.map(s => s.recordedId)).toEqual(['parent', 'early'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('installLlmReplay (per-session keying)', () => {
|
||||
const second: StreamChunk[] = [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: 'child' },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
|
||||
/** Write a session log file and return its path. */
|
||||
function writeSession(filename: string, header: { id: string; createdAt: number }, calls: StreamChunk[][]): string {
|
||||
let seq = 1
|
||||
const events: SessionEvent[] = []
|
||||
calls.forEach((chunks, step) => { for (const c of chunks) events.push(chunkEvent(seq++, 1, step + 1, c)) })
|
||||
const path = join(dir, filename)
|
||||
writeFileSync(path, sessionJsonl(events, header), 'utf8')
|
||||
return path
|
||||
}
|
||||
|
||||
const live = (id: string): GenerateOptions =>
|
||||
({ model: 'm', messages: [], sessionId: id as NonNullable<GenerateOptions['sessionId']> })
|
||||
|
||||
it('routes each live session to its own script by FIRST-CALL order', async () => {
|
||||
const parentFile = writeSession('session.jsonl', { id: 'rec-parent', createdAt: 100 }, [TEXT_CHUNKS])
|
||||
const childFile = writeSession('session.1.jsonl', { id: 'rec-child', createdAt: 200 }, [second])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
installLlmReplay(ctx, { file: parentFile, childFiles: [childFile] })
|
||||
// The first live session to call binds to the parent script; a different
|
||||
// live session id binds to the child script — regardless of recorded ids.
|
||||
expect(await drain(ctx.llm.stream(live('live-A')))).toEqual(TEXT_CHUNKS)
|
||||
expect(await drain(ctx.llm.stream(live('live-B')))).toEqual(second)
|
||||
// The first session's SECOND call would exhaust its 1-entry script.
|
||||
await expect(drain(ctx.llm.stream(live('live-A')))).rejects.toThrow(/exhausted/)
|
||||
})
|
||||
|
||||
it('keeps each session\'s cursor independent (interleaved calls)', async () => {
|
||||
const a2: StreamChunk[] = [{ type: 'text-delta', index: 0, text: 'a2' }, { type: 'finish', reason: { kind: 'stop' } }]
|
||||
const b2: StreamChunk[] = [{ type: 'text-delta', index: 0, text: 'b2' }, { type: 'finish', reason: { kind: 'stop' } }]
|
||||
const parentFile = writeSession('session.jsonl', { id: 'p', createdAt: 1 }, [TEXT_CHUNKS, a2])
|
||||
const childFile = writeSession('session.1.jsonl', { id: 'c', createdAt: 2 }, [second, b2])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
installLlmReplay(ctx, { file: parentFile, childFiles: [childFile] })
|
||||
// Interleave: A#1, B#1, A#2, B#2 — each cursor advances per-session.
|
||||
expect(await drain(ctx.llm.stream(live('A')))).toEqual(TEXT_CHUNKS)
|
||||
expect(await drain(ctx.llm.stream(live('B')))).toEqual(second)
|
||||
expect(await drain(ctx.llm.stream(live('A')))).toEqual(a2)
|
||||
expect(await drain(ctx.llm.stream(live('B')))).toEqual(b2)
|
||||
})
|
||||
|
||||
it('treats a call with no sessionId as the single anonymous (primary) session', async () => {
|
||||
const parentFile = writeSession('session.jsonl', { id: 'p', createdAt: 1 }, [TEXT_CHUNKS])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
installLlmReplay(ctx, { file: parentFile })
|
||||
// No sessionId at all — the legacy single-session path.
|
||||
expect(await drain(ctx.llm.stream({ model: 'm', messages: [] }))).toEqual(TEXT_CHUNKS)
|
||||
})
|
||||
|
||||
it('fails loud when more distinct live sessions call than were recorded', async () => {
|
||||
const parentFile = writeSession('session.jsonl', { id: 'p', createdAt: 1 }, [TEXT_CHUNKS])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
installLlmReplay(ctx, { file: parentFile }) // only ONE recorded session
|
||||
expect(await drain(ctx.llm.stream(live('first')))).toEqual(TEXT_CHUNKS)
|
||||
// A SECOND distinct live session has no script to bind to.
|
||||
await expect(drain(ctx.llm.stream(live('second')))).rejects.toThrow(/unrecorded session/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('apply (the plugin entry)', () => {
|
||||
const ORIG = { file: process.env.DSH_SNAPSHOT_FILE, override: process.env.DSH_SNAPSHOT_OVERRIDE }
|
||||
const ORIG = {
|
||||
file: process.env.DSH_SNAPSHOT_FILE,
|
||||
override: process.env.DSH_SNAPSHOT_OVERRIDE,
|
||||
children: process.env.DSH_SNAPSHOT_CHILD_FILES,
|
||||
}
|
||||
afterEach(() => {
|
||||
if (ORIG.file === undefined) delete process.env.DSH_SNAPSHOT_FILE
|
||||
else process.env.DSH_SNAPSHOT_FILE = ORIG.file
|
||||
if (ORIG.override === undefined) delete process.env.DSH_SNAPSHOT_OVERRIDE
|
||||
else process.env.DSH_SNAPSHOT_OVERRIDE = ORIG.override
|
||||
if (ORIG.children === undefined) delete process.env.DSH_SNAPSHOT_CHILD_FILES
|
||||
else process.env.DSH_SNAPSHOT_CHILD_FILES = ORIG.children
|
||||
})
|
||||
|
||||
it('exposes the namespace plugin shape (name/inject, no default export)', () => {
|
||||
@@ -418,4 +633,52 @@ describe('apply (the plugin entry)', () => {
|
||||
await ctx.plugin(LlmService)
|
||||
expect(() => { apply(ctx, { file: '' }) }).toThrow(/a fixture path is required/)
|
||||
})
|
||||
|
||||
it('loads child fixtures from config.childFiles (per-session routing)', async () => {
|
||||
const childSecond: StreamChunk[] = [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: 'kid' },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c)), { id: 'p', createdAt: 1 }), 'utf8')
|
||||
const childFile = join(dir, 'session.1.jsonl')
|
||||
writeFileSync(childFile, sessionJsonl(childSecond.map((c, i) => chunkEvent(i + 1, 1, 1, c)), { id: 'c', createdAt: 2 }), 'utf8')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
apply(ctx, { file, childFiles: [childFile] })
|
||||
const live = (id: string): GenerateOptions =>
|
||||
({ model: 'm', messages: [], sessionId: id as NonNullable<GenerateOptions['sessionId']> })
|
||||
expect(await drain(ctx.llm.stream(live('A')))).toEqual(TEXT_CHUNKS)
|
||||
expect(await drain(ctx.llm.stream(live('B')))).toEqual(childSecond)
|
||||
})
|
||||
|
||||
it('falls back to $DSH_SNAPSHOT_CHILD_FILES (path-delimited) when config omits childFiles', async () => {
|
||||
const childChunks: StreamChunk[] = [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: 'env-kid' },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c)), { id: 'p', createdAt: 1 }), 'utf8')
|
||||
const childFile = join(dir, 'session.1.jsonl')
|
||||
writeFileSync(childFile, sessionJsonl(childChunks.map((c, i) => chunkEvent(i + 1, 1, 1, c)), { id: 'c', createdAt: 2 }), 'utf8')
|
||||
process.env.DSH_SNAPSHOT_FILE = file
|
||||
process.env.DSH_SNAPSHOT_CHILD_FILES = childFile // single entry, no delimiter needed
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
apply(ctx)
|
||||
const live = (id: string): GenerateOptions =>
|
||||
({ model: 'm', messages: [], sessionId: id as NonNullable<GenerateOptions['sessionId']> })
|
||||
expect(await drain(ctx.llm.stream(live('A')))).toEqual(TEXT_CHUNKS)
|
||||
expect(await drain(ctx.llm.stream(live('B')))).toEqual(childChunks)
|
||||
})
|
||||
|
||||
it('ignores an empty $DSH_SNAPSHOT_CHILD_FILES (single-session)', async () => {
|
||||
writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c)), { id: 'p', createdAt: 1 }), 'utf8')
|
||||
process.env.DSH_SNAPSHOT_FILE = file
|
||||
process.env.DSH_SNAPSHOT_CHILD_FILES = ''
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
apply(ctx)
|
||||
expect(await drain(ctx.llm.stream({ model: 'm', messages: [] }))).toEqual(TEXT_CHUNKS)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
|
||||
19
packages/support/subagent-mock/README.md
Normal file
19
packages/support/subagent-mock/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# @deepseek-ai/dsh-subagent-mock
|
||||
|
||||
A scripted `SubagentProvider` for testing the [subagent seam](../../subagent/subagent/README.md) without a model or a real child agent — the subagent analog of [`dsh-llm-replay`](../llm-replay/README.md).
|
||||
|
||||
It lets a test drive `ctx.subagents` and the model-facing `dsh-tool-subagent` through the **real cordis Loader / export path**, exercising provider registration, start-time capability validation, the run lifecycle (`result` / `cancel` / `dispose`), and the structured-output branch — all deterministically and keylessly.
|
||||
|
||||
## Usage
|
||||
|
||||
Load it as a plugin (functional shape: `name`/`inject`/`Config`/`apply`, no default). Config (all optional):
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `name` | `mock` | Registry name to register the provider under. |
|
||||
| `reply` | `mock subagent reply` | The scripted child's final answer text. |
|
||||
| `stopReason` | `completed` | The stop reason `result` settles with. |
|
||||
| `capabilities` | all `true` | Which start-time capabilities (`outputSchema`/`depthLimit`/`toolFilter`) the provider advertises. |
|
||||
| `structured` | `{ reply }` | Structured value surfaced when a request carries an `outputSchema` and the capability is on. |
|
||||
|
||||
A `cancel()` issued before `result` settles flips the stop reason to `aborted`, so the cancellation path is observable.
|
||||
40
packages/support/subagent-mock/package.json
Normal file
40
packages/support/subagent-mock/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-subagent-mock",
|
||||
"description": "Scripted subagent provider for testing the subagent seam (keyless, deterministic)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^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-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
112
packages/support/subagent-mock/src/index.ts
Normal file
112
packages/support/subagent-mock/src/index.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* A scripted {@link SubagentProvider} for testing the subagent seam WITHOUT a
|
||||
* model or a real child agent. Mirrors `@deepseek-ai/dsh-llm-replay`: it lets a
|
||||
* test drive the service and the model-facing tool through the REAL cordis
|
||||
* Loader / export path, exercising registration, capability validation, the
|
||||
* run lifecycle, and the structured-output branch deterministically.
|
||||
*
|
||||
* Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default —
|
||||
* a functional plugin (it only registers a provider; it is never injected).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent-mock
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
SubagentCapabilities,
|
||||
SubagentProvider,
|
||||
SubagentResult,
|
||||
SubagentRun,
|
||||
SubagentStartRequest,
|
||||
SubagentStopReason,
|
||||
} from '@deepseek-ai/dsh-subagent'
|
||||
|
||||
const STOP_REASONS = ['completed', 'aborted', 'error', 'max-tokens', 'refusal'] as const
|
||||
|
||||
const DEFAULT_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true }
|
||||
|
||||
/**
|
||||
* A scripted provider: every {@link start} returns a run whose `result`
|
||||
* resolves on a microtask with the configured reply (and a structured value
|
||||
* when the request asked for one and the capability is on). `dispose` is a
|
||||
* no-op; a `cancel()` before the result settles flips the stop reason to
|
||||
* `aborted`, so the cancellation path is observable in a test.
|
||||
*/
|
||||
class MockSubagentProvider implements SubagentProvider {
|
||||
readonly capabilities: SubagentCapabilities
|
||||
|
||||
constructor(
|
||||
readonly name: string,
|
||||
private readonly config: Config,
|
||||
) {
|
||||
this.capabilities = { ...DEFAULT_CAPS, ...config.capabilities }
|
||||
}
|
||||
|
||||
start(request: SubagentStartRequest): SubagentRun {
|
||||
const reply = this.config.reply ?? 'mock subagent reply'
|
||||
const output: ContentBlock[] = [{ type: 'text', text: reply }]
|
||||
const wantsStructured = request.outputSchema !== undefined && this.capabilities.outputSchema
|
||||
const baseStop: SubagentStopReason = this.config.stopReason ?? 'completed'
|
||||
let cancelled = false
|
||||
|
||||
// A deterministic child id derived from the parent — no clock/random (both
|
||||
// banned in deterministic paths here, and unnecessary for a scripted run).
|
||||
const id = AgentId(`mock-subagent:${this.name}:${request.parent.id}`)
|
||||
|
||||
const resultFor = (): SubagentResult => ({
|
||||
output,
|
||||
structured: wantsStructured ? (this.config.structured ?? { reply }) : undefined,
|
||||
stopReason: cancelled ? 'aborted' : baseStop,
|
||||
})
|
||||
|
||||
return {
|
||||
id,
|
||||
result: Promise.resolve().then(resultFor),
|
||||
cancel() {
|
||||
cancelled = true
|
||||
},
|
||||
async dispose() {
|
||||
// Scripted run holds no resources — nothing to await.
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const name = 'subagent-mock'
|
||||
export const inject = ['subagents']
|
||||
|
||||
/** Config for the mock provider; all optional with test-friendly defaults. */
|
||||
export interface Config {
|
||||
/** Registry name to register under. */
|
||||
name: string
|
||||
/** The text the scripted child "returns" as its final answer. */
|
||||
reply?: string
|
||||
/** The stop reason the run settles with. */
|
||||
stopReason?: SubagentStopReason
|
||||
/** Which start-time capabilities to advertise (default: all `true`). */
|
||||
capabilities?: Partial<SubagentCapabilities>
|
||||
/**
|
||||
* Structured value surfaced when a request carries an `outputSchema` and the
|
||||
* `outputSchema` capability is on (default: `{ reply }`).
|
||||
*/
|
||||
structured?: unknown
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
name: z.string().default('mock'),
|
||||
reply: z.string(),
|
||||
stopReason: z.union(STOP_REASONS),
|
||||
capabilities: z.object({
|
||||
outputSchema: z.boolean(),
|
||||
depthLimit: z.boolean(),
|
||||
toolFilter: z.boolean(),
|
||||
}),
|
||||
structured: z.any(),
|
||||
})
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
ctx.subagents.registerProvider(new MockSubagentProvider(config.name, config))
|
||||
}
|
||||
101
packages/support/subagent-mock/tests/subagent-mock.spec.ts
Normal file
101
packages/support/subagent-mock/tests/subagent-mock.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import * as mock from '../src/index.ts'
|
||||
|
||||
/** A minimal parent — the mock provider only reads `parent.id`. */
|
||||
function fakeParent(id = 'parent-1'): Agent {
|
||||
return { id: AgentId(id) } as unknown as Agent
|
||||
}
|
||||
|
||||
function baseRequest(over: Partial<SubagentStartRequest> = {}): SubagentStartRequest {
|
||||
return { prompt: [{ type: 'text', text: 'task' }], parent: fakeParent(), ...over }
|
||||
}
|
||||
|
||||
async function mount(config: Partial<mock.Config> = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(mock, { name: 'mock', ...config })
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('dsh-subagent-mock', () => {
|
||||
it('registers a provider on ctx.subagents and returns the scripted reply', async () => {
|
||||
const ctx = await mount({ reply: 'hello from mock' })
|
||||
expect(ctx.subagents.list()).toEqual(['mock'])
|
||||
|
||||
const run = ctx.subagents.start('mock', baseRequest())
|
||||
await expect(run.result).resolves.toEqual({
|
||||
output: [{ type: 'text', text: 'hello from mock' }],
|
||||
structured: undefined,
|
||||
stopReason: 'completed',
|
||||
})
|
||||
})
|
||||
|
||||
it('registers under a configurable name', async () => {
|
||||
const ctx = await mount({ name: 'spawn' })
|
||||
expect(ctx.subagents.list()).toEqual(['spawn'])
|
||||
})
|
||||
|
||||
it('surfaces a structured result when the request carries an outputSchema', async () => {
|
||||
const ctx = await mount({ reply: 'r', structured: { answer: 42 } })
|
||||
const run = ctx.subagents.start('mock', baseRequest({ outputSchema: { answer: { type: 'number' } } }))
|
||||
await expect(run.result).resolves.toMatchObject({ structured: { answer: 42 } })
|
||||
})
|
||||
|
||||
it('defaults structured output to { reply } when outputSchema is requested but no structured value is configured', async () => {
|
||||
const ctx = await mount({ reply: 'fallback reply' })
|
||||
const run = ctx.subagents.start('mock', baseRequest({ outputSchema: { answer: { type: 'number' } } }))
|
||||
await expect(run.result).resolves.toMatchObject({ structured: { reply: 'fallback reply' } })
|
||||
})
|
||||
|
||||
it('omits structured output when outputSchema capability is off', async () => {
|
||||
const ctx = await mount({ capabilities: { outputSchema: false } })
|
||||
// The service rejects an outputSchema request against a no-cap provider, so
|
||||
// the structured path is only reachable when the cap is on; with it off and
|
||||
// no schema requested, the result has no structured field.
|
||||
const run = ctx.subagents.start('mock', baseRequest())
|
||||
await expect(run.result).resolves.toMatchObject({ structured: undefined })
|
||||
})
|
||||
|
||||
it('honors a configured stop reason', async () => {
|
||||
const ctx = await mount({ stopReason: 'refusal' })
|
||||
const run = ctx.subagents.start('mock', baseRequest())
|
||||
await expect(run.result).resolves.toMatchObject({ stopReason: 'refusal' })
|
||||
})
|
||||
|
||||
it('flips the stop reason to aborted when cancelled before the result settles', async () => {
|
||||
const ctx = await mount()
|
||||
const run = ctx.subagents.start('mock', baseRequest())
|
||||
run.cancel()
|
||||
await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted' })
|
||||
})
|
||||
|
||||
it('unregisters the provider when the owning fiber is disposed (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
const fiber = await ctx.plugin(mock, { name: 'mock' })
|
||||
expect(ctx.subagents.list()).toEqual(['mock'])
|
||||
await fiber.dispose()
|
||||
expect(ctx.subagents.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/Config/apply', () => {
|
||||
// Postmortem 0001 guard: this plugin HAS `inject = ['subagents']`, so a stray
|
||||
// `export default apply` would collapse the module via `unwrapExports`
|
||||
// (`exports.default ?? exports`), DROP `inject`, and crash at load with
|
||||
// "cannot get property … without inject". Guard the shape directly.
|
||||
expect('default' in mock).toBe(false)
|
||||
expect(mock.name).toBe('subagent-mock')
|
||||
expect(mock.inject).toEqual(['subagents'])
|
||||
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(mock) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(mock)
|
||||
expect(unwrapped.name).toBe('subagent-mock')
|
||||
expect(unwrapped.inject).toEqual(['subagents'])
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
30
packages/support/subagent-mock/tsconfig.json
Normal file
30
packages/support/subagent-mock/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/subagent"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
|
||||
@@ -5,24 +5,27 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"bin": {
|
||||
"dsh-acp-agent": "lib/bin.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/index.d.ts",
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./bin": {
|
||||
"types": "./lib/bin.d.ts",
|
||||
"types": "./lib/types/bin.d.ts",
|
||||
"default": "./lib/bin.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib",
|
||||
"lib/index.js",
|
||||
"lib/bin.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
|
||||
@@ -3,11 +3,12 @@ import { defineConfig } from 'tsdown'
|
||||
/**
|
||||
* acp-agent ships TWO entries: the plugin (`index`) and the CLI `bin` (`bin`),
|
||||
* the latter referenced by package.json `bin`/`exports["./bin"]`. The root
|
||||
* tsdown builds only `src/index.ts`, so this override adds `bin.ts`.
|
||||
* Declarations come from `tsc -b` (dts: false), matching every package.
|
||||
* tsdown builds only `lib/types/index.js`, so this override adds
|
||||
* `lib/types/bin.js`. Declarations come from `tsc -b` (dts: false),
|
||||
* matching every package.
|
||||
*/
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts', 'src/bin.ts'],
|
||||
entry: ['lib/types/index.js', 'lib/types/bin.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user