refactor(subagent): unify async readiness and cancellation

This commit is contained in:
Tianyi Cui
2026-07-12 22:41:59 +08:00
parent 02ca71db57
commit bb3f6bd736
49 changed files with 1350 additions and 4147 deletions

View File

@@ -1,16 +1,16 @@
# @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 spawn provider creates a fresh child `Agent` in the current process. The child has its own session, sees no parent conversation history, and reuses the host's agent factory and LLM/tool services.
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.
## Behavior
## What it does
`start(request)` delegates to [`startInProcessRun`](../subagent-inprocess/README.md) with no seed and awaits publication before returning. The child receives parent working-directory/session lineage and inherits the parent model unless overridden, but starts with an empty conversation.
`start(request)` delegates to `startInProcessRun(ctx, request, {})` with no seed: a fresh child agent with the parent's `cwd`/`parentSession` lineage and (by default) the parent's model. The driver creates one run-owner fiber under `parent.ctx`; parent teardown, this provider's teardown, manual disposal, and cancellation before readiness all converge there before child publication. Its `run.started` boundary resolves only after the fresh child is published, so `subagent/start` observers see a live registry entry; a same-tick cancel deactivates the unpublished transaction instead, rejects readiness, resolves the result as `aborted`, and emits no agent/session or subagent lifecycle. See the [driver README](../subagent-inprocess/README.md) for the full lifecycle (depth check, one-shot drive, result read, dispose).
The shared driver owns depth checking, persona and tool-filter setup, structured output, required-signal cancellation, one-shot execution, result reading, and quiescent disposal. A startup rejection leaves no published child; provider unload after fulfillment does not revoke the holder-owned run.
## Capabilities
`{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`. It constructs the child, so it enforces a recursion cap and composes the child's persona, global-tool restriction, and [structured runtime](../subagent-inprocess/README.md) inside the agent-creation setup window. At apply it registers this one named provider on `ctx.subagents`; per-run contributions belong to each child's scope.
Spawn advertises `{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }` because it controls the child's creation window and can enforce all four features.
## Config

View File

@@ -53,16 +53,16 @@ class SpawnProvider implements SubagentProvider {
// Context contract: a spawned child starts fresh — it never sees the parent conversation.
readonly inheritsParentContext = false
constructor(readonly name: string, private readonly ctx: Context) {}
constructor(readonly name: string) {}
start(request: SubagentStartRequest) {
// Fresh child: no seed. The shared driver mints ids, stamps cwd/lineage/
// depth, drives the one-shot (including the structured capture when the
// request carries an outputSchema), and maps the result.
return startInProcessRun(this.ctx, request, {})
return startInProcessRun(request, {})
}
}
export function apply(ctx: Context, config: Config): void {
ctx.subagents.registerProvider(new SpawnProvider(config.providerName, ctx))
ctx.subagents.registerProvider(new SpawnProvider(config.providerName))
}

View File

@@ -9,7 +9,7 @@ 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 SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import * as spawn from '../src/index.ts'
import { depthOf, STRUCTURED_OUTPUT_TOOL, SubagentDepthError } from '@deepseek-ai/dsh-subagent-inprocess'
@@ -44,11 +44,15 @@ function text(blocks: { type: string; text?: string }[]): string {
return blocks.filter(b => b.type === 'text').map(b => b.text).join('')
}
function start(ctx: Context, provider: string, request: Omit<SubagentStartRequest, 'signal'> & { signal?: AbortSignal }) {
return ctx.subagents.start(provider, { signal: request.signal ?? new AbortController().signal, ...request })
}
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 run = await start(ctx, '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')
@@ -62,11 +66,11 @@ describe('dsh-subagent-spawn', () => {
if (info.provider === 'spawn') childAtStart = ctx.agents.get(info.id)
})
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'do X' }], parent })
const starting = start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'do X' }], parent })
// Creation is asynchronous; no lifecycle claim is made while the child is
// still inside its unpublished setup transaction.
expect(childAtStart).toBeUndefined()
await run.started
const run = await starting
expect(childAtStart).toBe(ctx.agents.get(run.id))
expect(childAtStart?.id).toBe(run.id)
@@ -76,7 +80,7 @@ describe('dsh-subagent-spawn', () => {
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 })
const run = await start(ctx, '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)
@@ -92,7 +96,7 @@ describe('dsh-subagent-spawn', () => {
const parentEventCount = parent.session.events.length
expect(parentEventCount).toBeGreaterThan(0)
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'child prompt' }], parent })
const run = await start(ctx, '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.
@@ -103,7 +107,7 @@ describe('dsh-subagent-spawn', () => {
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 })
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
await run.result
expect(ctx.agents.get(run.id)).toBeDefined()
await run.dispose()
@@ -114,7 +118,7 @@ describe('dsh-subagent-spawn', () => {
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 })
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
await run.result
const child = ctx.agents.get(run.id)!
expect(depthOf(child)).toBe(1)
@@ -124,13 +128,13 @@ describe('dsh-subagent-spawn', () => {
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)
await expect(start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }))
.rejects.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 run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
const result = await run.result
expect(result.stopReason).toBe('max-tokens')
await run.dispose()
@@ -140,14 +144,14 @@ describe('dsh-subagent-spawn', () => {
// 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 run = await start(ctx, '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 () => {
it('rejects without publishing 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
@@ -156,15 +160,12 @@ describe('dsh-subagent-spawn', () => {
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()
await expect(start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal }))
.rejects.toThrow('aborted before child publication')
})
it('same-tick cancellation rejects readiness and prevents child publication', async () => {
// Regression: cancellation before readiness used to set a flag but let the
it('same-tick cancellation rejects start and prevents child publication', async () => {
// Regression: cancellation before publication used to set a flag but let the
// async factory publish a child anyway, so `started` fulfilled and lifecycle
// observers saw an agent for an attempt the caller had already cancelled.
// The empty script also proves no model turn can run.
@@ -177,14 +178,12 @@ describe('dsh-subagent-spawn', () => {
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
ctx.on('subagent/start', () => void published.push('subagent/start'))
ctx.on('subagent/end', () => void published.push('subagent/end'))
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
run.cancel('early')
const controller = new AbortController()
const starting = start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal })
controller.abort('early')
await expect(run.started).rejects.toThrow()
await expect(run.result).resolves.toEqual({ output: [], stopReason: 'aborted' })
await run.dispose()
await expect(starting).rejects.toThrow()
await Promise.resolve()
expect(ctx.agents.get(run.id)).toBeUndefined()
expect(ctx.agents.list()).toHaveLength(beforeAgents)
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
expect(published).toEqual([])
@@ -192,10 +191,9 @@ describe('dsh-subagent-spawn', () => {
it('a cancel from agent/queued maps a no-turn child log to aborted', async () => {
const { ctx, parent } = await setup([])
ctx.on('agent/queued', (agent) => {
if (agent.id === run.id) run.cancel('queued-window')
})
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
const controller = new AbortController()
ctx.on('agent/queued', () => { controller.abort('queued-window') })
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal })
const result = await run.result
expect(result).toMatchObject({ stopReason: 'aborted', output: [] })
const child = ctx.agents.get(run.id)!
@@ -203,30 +201,11 @@ describe('dsh-subagent-spawn', () => {
await run.dispose()
})
it('dispose during async child creation waits for rollback and leaves no orphan', async () => {
const { ctx, parent } = await setup([])
const beforeAgents = ctx.agents.list().length
const beforeSessions = ctx.sessions.list().length
const published: string[] = []
ctx.on('session/created', () => void published.push('session/created'))
ctx.on('agent/created', () => void published.push('agent/created'))
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
// Same tick: the factory has reserved ids and entered its async setup
// transaction, but has not published the child yet.
await run.dispose()
await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted', output: [] })
expect(ctx.agents.list()).toHaveLength(beforeAgents)
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
expect(published).toEqual([])
})
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 })
const run = await start(ctx, '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))
@@ -236,29 +215,18 @@ describe('dsh-subagent-spawn', () => {
await run.dispose()
})
it('run.cancel() also cancels the child directly', async () => {
it('dispose cancels the child and reaches quiescence', async () => {
const { ctx, parent } = await setup(['hang'])
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
await new Promise(r => setTimeout(r, 30))
run.cancel('test cancel')
await run.dispose()
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 })
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
expect('sendMessage' in run).toBe(false)
expect('resume' in run).toBe(false)
await run.result
@@ -274,7 +242,7 @@ describe('dsh-subagent-spawn', () => {
meta: { cwd: '/tmp/parent-workspace' },
agentOptions: { model: 'mock' },
})
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent: parentHandle.agent })
const run = await start(ctx, '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')
@@ -291,7 +259,7 @@ describe('dsh-subagent-spawn', () => {
agentOptions: {},
})
// The request supplies the child's model explicitly.
const run = ctx.subagents.start('spawn', {
const run = await start(ctx, 'spawn', {
prompt: [{ type: 'text', text: 'p' }],
parent: parentHandle.agent,
agentOptions: { model: 'mock' },
@@ -323,7 +291,7 @@ describe('dsh-subagent-spawn', () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42 }),
])
const run = ctx.subagents.start('spawn', {
const run = await start(ctx, 'spawn', {
prompt: [{ type: 'text', text: 'produce the answer' }],
parent,
outputSchema: { type: 'object', properties: { answer: { type: 'number' } }, required: ['answer'] },
@@ -336,7 +304,7 @@ describe('dsh-subagent-spawn', () => {
await run.dispose()
})
it('a backend unload mid-structured-run settles the run and releases the runtime', async () => {
it('a backend unload does not revoke an accepted holder-owned run', async () => {
// Rebuild the stack by hand so we hold the backend's fiber.
const ctx = new Context()
const adapter = new MockAdapter(['hang'])
@@ -351,52 +319,27 @@ describe('dsh-subagent-spawn', () => {
const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
ctx.llm.registerAdapter(['mock'], adapter)
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
const run = ctx.subagents.start('spawn', {
const controller = new AbortController()
const run = await start(ctx, 'spawn', {
prompt: [{ type: 'text', text: 'q' }],
parent,
signal: controller.signal,
outputSchema: { type: 'object', properties: { a: { type: 'number' } } },
})
// Let the child's step start streaming, then unload the backend. The
// backend owns the child agent, so the unload tears the child down and
// the run settles — releasing its own runtime acquisition on the way out.
// Provider removal prevents new starts but the returned run belongs to its
// holder and remains live.
await new Promise(resolve => setTimeout(resolve, 30))
await fiber.dispose()
expect(ctx.subagents.getProvider('spawn')).toBeUndefined()
expect(ctx.agents.get(run.id)).toBeDefined()
controller.abort('test complete')
const result = await run.result
expect(result.stopReason).toBe('error')
expect(result.stopReason).toBe('aborted')
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
await run.dispose()
})
it('a backend unload during child creation prevents every publication notification', async () => {
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)
const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
const published: string[] = []
ctx.on('session/created', () => void published.push('session/created'))
ctx.on('agent/created', () => void published.push('agent/created'))
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
const run = ctx.subagents.start('spawn', {
prompt: [{ type: 'text', text: 'must never run' }], parent,
})
await fiber.dispose()
await run.result.catch(() => undefined)
await run.dispose()
expect(ctx.agents.get(run.id)).toBeUndefined()
expect(published).toEqual([])
})
it('a start racing an already-unloading backend cannot mint a run-owner fiber', async () => {
it('a start racing an already-unloading backend cannot begin child creation', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
@@ -413,10 +356,10 @@ describe('dsh-subagent-spawn', () => {
ctx.on('agent/created', () => void published.push('agent/created'))
const unloading = fiber.dispose()
expect(() => ctx.subagents.start('spawn', {
prompt: [{ type: 'text', text: 'must never start' }], parent,
})).toThrow(/inactive context/)
await unloading
await expect(start(ctx, 'spawn', {
prompt: [{ type: 'text', text: 'must never start' }], parent,
})).rejects.toThrow(/no subagent provider/)
expect(parent.ctx.fiber.getEffects()).toHaveLength(parentEffects)
expect(published).toEqual([])
@@ -443,7 +386,7 @@ describe('dsh-subagent-spawn', () => {
parent.send([{ type: 'text', text: 'hi' }])
await parent.whenIdle()
const run = ctx.subagents.start('spawn', {
const run = await start(ctx, 'spawn', {
prompt: [{ type: 'text', text: 'do X' }],
parent,
persona: 'You are the tersest test runner.',
@@ -466,7 +409,7 @@ describe('dsh-subagent-spawn', () => {
name: 'forbidden_tool', description: 'global', parameters: {},
execute: () => Promise.resolve([{ type: 'text', text: 'ran' }]),
})
const run = ctx.subagents.start('spawn', {
const run = await start(ctx, 'spawn', {
prompt: [{ type: 'text', text: 'do X' }],
parent,
toolFilter: { deny: ['forbidden_tool'] },
@@ -486,13 +429,11 @@ describe('dsh-subagent-spawn', () => {
it('an unknown toolFilter name fails the spawn loudly with no orphaned child', async () => {
const { ctx, parent } = await setup([])
const before = ctx.agents.list().length
const run = ctx.subagents.start('spawn', {
await expect(start(ctx, 'spawn', {
prompt: [{ type: 'text', text: 'do X' }],
parent,
toolFilter: { deny: ['no_such_tool'] },
})
await expect(run.result).rejects.toThrow(/unknown tool "no_such_tool"/)
await run.dispose()
})).rejects.toThrow(/unknown global tool "no_such_tool"/)
expect(ctx.agents.list().length).toBe(before)
})
})
@@ -512,12 +453,10 @@ describe('dsh-subagent-spawn', () => {
ctx.on('session/created', () => void published.push('session/created'))
ctx.on('agent/created', () => void published.push('agent/created'))
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
const run = ctx.subagents.start('spawn', {
await expect(start(ctx, 'spawn', {
prompt: [{ type: 'text', text: 'do X' }],
parent: parentHandle.agent,
})
await expect(run.result).rejects.toThrow(/inactive context/)
await run.dispose()
})).rejects.toThrow(/inactive context/)
expect(ctx.agents.list().length).toBe(before)
expect(ctx.sessions.list()).toHaveLength(sessionsBefore)
expect(published).toEqual([])
@@ -535,18 +474,16 @@ describe('dsh-subagent-spawn', () => {
ctx.on('agent/created', () => void published.push('agent/created'))
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
const run = ctx.subagents.start('spawn', {
const starting = start(ctx, 'spawn', {
prompt: [{ type: 'text', text: 'must never run' }],
parent: parentHandle.agent,
})
// The factory has entered its awaited unpublished setup transaction. Parent
// ownership was installed before that await, so disposal wins without an
// The factory has entered its awaited unpublished setup transaction. The
// parent context owns that transaction, so disposal wins without an
// observer ever seeing the child.
await parentHandle.dispose()
await expect(run.result).rejects.toThrow(/owner disposed during setup|inactive context/)
await run.dispose()
await expect(starting).rejects.toThrow(/owner disposed during setup|inactive context/)
expect(ctx.agents.get(run.id)).toBeUndefined()
expect(published).toEqual([])
})
})