Add in-process subagent backends: spawn (fresh) and fork (seeded)
The second PR of the subagent seam: the two in-process backends that run a
child agent on the same cordis context, reusing the agent factory's quiescent
AgentHandle teardown. Both register on ctx.subagents (PR1's named-provider
registry) and share one run driver.
- dsh-subagent-spawn: a FRESH child via ctx.agents.create — own session, the
parent's model by default (overridable), zero inherited conversation. Also
exports the shared in-process run driver (startInProcessRun): mint ids, stamp
cwd/parentSession-lineage/depth, drive the one-shot (send → whenIdle), read
the last assistant/message + turn/end reason, dispose to quiescence.
- dsh-subagent-fork: a child SEEDED with the parent's balanced completed-turn
prefix (the log up to and including its last turn/end), so the child inherits
context. The in-flight unbalanced turn is excluded — a raw seed would fail the
invariants replay. Proven: a regression test goes red if the boundary seeds
the open turn.
- Seam extension: CreateAgentOptions.seed, threaded through AgentLoop.createAgent
→ ctx.sessions.prepare({ seed }) (the primitive resume already used). This is
the fork-lineage path the TODO(sub-agents) markers anticipated.
- Depth: a merge-extensible AgentOptions.subagentDepth (0 top-level, parent+1 for
a child); the depthLimit capability refuses a spawn past request.maxDepth.
Tests: real-loop unit tests for both backends (mock MODEL only, real loop +
invariants), a multi-subagent test (one parent drives a fork AND a spawn child
then keeps working), and a with-key e2e (a real parent delegates via the
`subagent` tool to a real child that writes a file on disk — world-verified).
100% per-file coverage. The coding-agent demo wires the spawn backend + tool.
Snapshot coverage of nested agents is deferred to a stacked follow-up
(TODO(subagent-snapshots)): dsh-llm-replay is a single global positional cursor
that cannot route calls to a parent vs. a child on one context. Recorded in the
RFC's deferrals and a new AGENTS.md rule: designing a subsystem must design its
test infrastructure END TO END up front, verifying the snapshot/e2e harness can
express the new shape — a gap this plan hit.
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
# @deepseek-ai/dsh-subagent-spawn
|
||||
|
||||
The in-process **spawn** subagent backend: a [`SubagentProvider`](../subagent/README.md) that runs each child as a **fresh** child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`) — its own session, its own (or the parent's) model, zero inherited conversation. The cheapest transport, reusing the agent factory's quiescent [`AgentHandle`](../../core/agent) teardown.
|
||||
|
||||
It also exports the **shared in-process run driver** (`startInProcessRun`) that the [fork](../subagent-fork/README.md) backend builds on — spawn and fork differ only in the session seed.
|
||||
|
||||
## What it does
|
||||
|
||||
`start(request)` →
|
||||
1. computes child depth = `depthOf(parent) + 1`; if `request.maxDepth` is set and exceeded, throws `SubagentDepthError` (the `depthLimit` capability);
|
||||
2. creates a child via `ctx.agents.create` with a fresh `AgentId`/`SessionId`, the parent's `cwd` + `parentSession` lineage, and `agentOptions` (the child inherits the **parent's model** by default — a child with no model can't run — overridable via `request.agentOptions.model`; the system prompt is NOT inherited);
|
||||
3. drives the one-shot: `child.send(prompt)` then `await child.whenIdle()` (ordering matters — `send` enqueues synchronously, so `whenIdle` observes the queued work and resolves on the child's `running → idle` transition, never before the turn starts);
|
||||
4. reads the result: the last `assistant/message` content (deep-cloned — the log is frozen) and the last `turn/end.reason` mapped to a `SubagentStopReason`.
|
||||
|
||||
`dispose()` delegates to `AgentHandle.dispose()` (stop loop → await quiescence → remove session); `cancel()` cancels the child's in-flight turn.
|
||||
|
||||
## Capabilities
|
||||
|
||||
`{ outputSchema: false, depthLimit: true, toolFilter: false }`. It constructs the child, so it enforces a recursion cap; structured output and tool-scoping are deferred (the service rejects a request needing either before `start` runs).
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Meaning |
|
||||
|---|---|
|
||||
| `providerName` | Registry name on `ctx.subagents` (default `spawn`). |
|
||||
|
||||
## Depth tracking
|
||||
|
||||
Delegation depth rides on a merge-extensible `AgentOptions.subagentDepth` field (0 for a top-level agent, parent + 1 for a child), so a nested spawn reads its parent's depth from `parent.options.subagentDepth`. Read it with the exported `depthOf(agent)`.
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-subagent-spawn",
|
||||
"description": "In-process spawn subagent backend: runs a fresh child agent on ctx.agents",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* The shared in-process subagent run driver. A subagent backend that runs the
|
||||
* child as a child {@link Agent} on the SAME cordis context (`ctx.agents`) —
|
||||
* the cheapest transport, reusing the agent factory's quiescent
|
||||
* {@link AgentHandle} teardown. Both in-process backends use this:
|
||||
* `@deepseek-ai/dsh-subagent-spawn` (a fresh child) and
|
||||
* `@deepseek-ai/dsh-subagent-fork` (a child seeded with a prefix of the
|
||||
* parent's log) differ ONLY in the `seed` they pass — everything downstream
|
||||
* (drive the child, read its final output, map the stop reason, dispose) is
|
||||
* identical and lives here.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent-spawn/in-process
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Context } from 'cordis'
|
||||
import { AgentId, type Agent, type AgentHandle, type AgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
|
||||
|
||||
declare module '@deepseek-ai/dsh-agent' {
|
||||
interface AgentOptions {
|
||||
/**
|
||||
* The agent's delegation depth in the subagent tree — 0 for a top-level
|
||||
* (config/ACP-created) agent, parent depth + 1 for a subagent. Set by the
|
||||
* in-process backends on every child they create so a nested spawn reads its
|
||||
* parent's depth from `parent.options.subagentDepth` and the `depthLimit`
|
||||
* capability can cap the tree. Merge-extensible field (the seam owns it; the
|
||||
* loop neither sets nor reads it).
|
||||
*/
|
||||
subagentDepth?: number
|
||||
}
|
||||
}
|
||||
|
||||
/** Read an agent's delegation depth (absent ⇒ a top-level agent, depth 0). */
|
||||
export function depthOf(agent: Agent): number {
|
||||
return agent.options.subagentDepth ?? 0
|
||||
}
|
||||
|
||||
/** Thrown when a spawn would exceed the request's `maxDepth` cap. */
|
||||
export class SubagentDepthError extends Error {
|
||||
constructor(public readonly attemptedDepth: number, public readonly maxDepth: number) {
|
||||
super(`subagent depth ${attemptedDepth} exceeds maxDepth ${maxDepth}`)
|
||||
this.name = 'SubagentDepthError'
|
||||
}
|
||||
}
|
||||
|
||||
/** Map a session `turn/end` reason to a {@link SubagentStopReason}. */
|
||||
function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason {
|
||||
switch (reason?.kind) {
|
||||
case 'completed':
|
||||
return 'completed'
|
||||
case 'max-tokens':
|
||||
return 'max-tokens'
|
||||
case 'aborted':
|
||||
return 'aborted'
|
||||
// `disposed` (torn down mid-turn) and `interrupted` (crash-closed) both mean
|
||||
// the turn did not finish cleanly; surface them as a generic failure rather
|
||||
// than a clean completion. A missing reason (no turn ran) is also an error.
|
||||
case 'error':
|
||||
case 'disposed':
|
||||
case 'interrupted':
|
||||
default:
|
||||
return 'error'
|
||||
}
|
||||
}
|
||||
|
||||
/** Extra inputs the spawn/fork backends supply to {@link startInProcessRun}. */
|
||||
export interface InProcessRunOptions {
|
||||
/** The provider name (`spawn`/`fork`), for error context only. */
|
||||
readonly providerName: string
|
||||
/**
|
||||
* The child session's seed: a balanced, contiguous-from-0 prefix of the
|
||||
* parent's log (FORK), or `undefined` for a fresh child (SPAWN).
|
||||
*/
|
||||
readonly seed?: SessionEvent[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an in-process child agent for `request` and return a {@link SubagentRun}.
|
||||
*
|
||||
* Drives the child as a one-shot: `send(prompt)` then `whenIdle()` (the ordering
|
||||
* matters — `send` enqueues synchronously, so `whenIdle` observes the queued
|
||||
* work and resolves only on the child's `running → idle` transition, never
|
||||
* before the turn starts). The final `assistant/message` is the result output,
|
||||
* the matching `turn/end.reason` the stop reason. `dispose()` delegates to the
|
||||
* factory's {@link AgentHandle.dispose} (stop loop → await quiescence → remove
|
||||
* session); `cancel()` cancels the child's in-flight turn.
|
||||
*/
|
||||
export function startInProcessRun(
|
||||
ctx: Context,
|
||||
request: SubagentStartRequest,
|
||||
options: InProcessRunOptions,
|
||||
): SubagentRun {
|
||||
const childDepth = depthOf(request.parent) + 1
|
||||
if (request.maxDepth !== undefined && childDepth > request.maxDepth) {
|
||||
throw new SubagentDepthError(childDepth, request.maxDepth)
|
||||
}
|
||||
|
||||
const childId = AgentId(randomUUID())
|
||||
const parentHeader = request.parent.session.header
|
||||
// Inherit the parent's model by default (a child with no model cannot run);
|
||||
// an explicit `request.agentOptions.model` overrides it. The parent's
|
||||
// systemPrompt is NOT inherited — a fresh child is a clean specialist unless
|
||||
// the caller supplies one.
|
||||
const agentOptions: AgentOptions = {
|
||||
...request.parent.options.model !== undefined ? { model: request.parent.options.model } : {},
|
||||
...request.agentOptions,
|
||||
subagentDepth: childDepth,
|
||||
}
|
||||
|
||||
const handle: AgentHandle = ctx.agents.create({
|
||||
agentId: childId,
|
||||
sessionId: SessionId(randomUUID()),
|
||||
meta: {
|
||||
...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {},
|
||||
parentSession: parentHeader.id,
|
||||
},
|
||||
...options.seed !== undefined ? { seed: options.seed } : {},
|
||||
agentOptions,
|
||||
})
|
||||
const child = handle.agent
|
||||
|
||||
// Bridge the request's abort signal to the child (the consumer also bridges
|
||||
// its own exec.signal, but a backend-level bridge keeps the contract local).
|
||||
const onAbort = (): void => { child.cancel('subagent cancelled') }
|
||||
request.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
const result: Promise<SubagentResult> = (async () => {
|
||||
try {
|
||||
child.send(request.prompt)
|
||||
await child.whenIdle()
|
||||
return readResult(child)
|
||||
} finally {
|
||||
request.signal?.removeEventListener('abort', onAbort)
|
||||
}
|
||||
})()
|
||||
|
||||
return {
|
||||
id: childId,
|
||||
result,
|
||||
cancel(reason?: string): void {
|
||||
child.cancel(reason ?? 'subagent cancelled')
|
||||
},
|
||||
async dispose(): Promise<void> {
|
||||
request.signal?.removeEventListener('abort', onAbort)
|
||||
await handle.dispose()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a settled child's terminal result from its session log: the last
|
||||
* `assistant/message` content (deep-cloned — the log is frozen) and the last
|
||||
* `turn/end` reason mapped to a {@link SubagentStopReason}.
|
||||
*/
|
||||
function readResult(child: Agent): SubagentResult {
|
||||
const events = child.session.events
|
||||
const lastMessage = events.findLast((e): e is SessionEvent<'assistant/message'> => e.type === 'assistant/message')
|
||||
const lastEnd = events.findLast((e): e is SessionEvent<'turn/end'> => e.type === 'turn/end')
|
||||
const output: ContentBlock[] = lastMessage ? structuredClone(lastMessage.data.content) : []
|
||||
return { output, stopReason: toStopReason(lastEnd?.data.reason) }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* The in-process SPAWN subagent backend: registers a {@link SubagentProvider}
|
||||
* on `ctx.subagents` that runs each child as a FRESH child {@link Agent} on the
|
||||
* same cordis context (its own session, own system prompt, zero parent
|
||||
* context). The cheapest transport, reusing the agent factory's quiescent
|
||||
* teardown.
|
||||
*
|
||||
* The fork sibling (`@deepseek-ai/dsh-subagent-fork`) shares this package's run
|
||||
* driver ({@link startInProcessRun}) and differs ONLY in seeding the child with
|
||||
* a prefix of the parent's log.
|
||||
*
|
||||
* Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent-spawn
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import { startInProcessRun } from './in-process.ts'
|
||||
|
||||
export { startInProcessRun, depthOf, SubagentDepthError } from './in-process.ts'
|
||||
export type { InProcessRunOptions } from './in-process.ts'
|
||||
|
||||
export const name = 'subagent-spawn'
|
||||
export const inject = ['subagents', 'agents']
|
||||
|
||||
/** Config: the registry name to register the provider under. */
|
||||
export interface Config {
|
||||
/** Provider name on `ctx.subagents` (default `spawn`). */
|
||||
providerName: string
|
||||
}
|
||||
|
||||
export const Config: z<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))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -0,0 +1,239 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import { MockAdapter, maxTokensResponse, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import * as spawn from '../src/index.ts'
|
||||
import { depthOf, SubagentDepthError } from '../src/in-process.ts'
|
||||
|
||||
type Script = ConstructorParameters<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('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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../subagent"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user