refactor(subagent): unify async readiness and cancellation
This commit is contained in:
@@ -1,23 +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** instead of starting with an empty conversation. The seed affects conversation history only. Tool registrations and restrictions follow the child's fresh flat scope; no parent/child authority relation is defined. Fork shares the run driver (`startInProcessRun`) with [`dsh-subagent-spawn`](../subagent-spawn/README.md); the only difference is the seed. The shared `run.started` boundary resolves only after the seeded child is published, so `subagent/start` observers see a live registry entry.
|
||||
The fork provider creates an in-process child seeded with the parent's completed conversation turns. It shares all run mechanics with spawn; the session seed is the only behavioral difference.
|
||||
|
||||
## The seed boundary (the crux)
|
||||
## Seed boundary
|
||||
|
||||
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**.
|
||||
The parent's current tool-calling turn is still open when a subagent starts: its log contains the assistant tool call but not the matching tool result or `turn/end`. Copying that raw log would give the child an invalid, unbalanced session.
|
||||
|
||||
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.
|
||||
Fork therefore uses `completedTurnPrefix(parent.session.events)`: the contiguous prefix ending at the last `turn/end`. The child sees all completed parent turns and none of the in-flight turn. If the parent has not completed a turn yet, the seed is empty and the child behaves like a fresh spawn.
|
||||
|
||||
The seam this rides on: `CreateAgentOptions.seed` (added on `dsh-agent`, threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`), the same primitive `resume` uses.
|
||||
The seed transfers conversation history only. The child still receives a fresh flat registration scope; it does not inherit the parent's tool restrictions or authority.
|
||||
|
||||
## Capabilities
|
||||
## Start and capabilities
|
||||
|
||||
`{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }` — identical to spawn because the shared driver owns depth, model, persona, tool-filter, and structured-output behavior.
|
||||
`start(request)` passes the completed-turn seed to [`startInProcessRun`](../subagent-inprocess/README.md) and awaits child publication. The shared driver owns cancellation, depth, customization, result reading, and disposal.
|
||||
|
||||
Fork advertises `{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`, identical to spawn.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -72,11 +72,11 @@ class ForkProvider implements SubagentProvider {
|
||||
// Context contract: a forked child IS seeded with the parent's completed-turn prefix.
|
||||
readonly inheritsParentContext = true
|
||||
|
||||
constructor(readonly name: string, private readonly ctx: Context) {}
|
||||
constructor(readonly name: string) {}
|
||||
|
||||
start(request: SubagentStartRequest) {
|
||||
const seed = completedTurnPrefix(request.parent)
|
||||
return startInProcessRun(this.ctx, request, {
|
||||
return startInProcessRun(request, {
|
||||
// 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 } : {},
|
||||
@@ -85,5 +85,5 @@ class ForkProvider implements SubagentProvider {
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
ctx.subagents.registerProvider(new ForkProvider(config.providerName, ctx))
|
||||
ctx.subagents.registerProvider(new ForkProvider(config.providerName))
|
||||
}
|
||||
|
||||
@@ -7,13 +7,17 @@ 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 SubagentService, { type SubagentStartRequest } 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]
|
||||
|
||||
function start(ctx: Context, provider: string, request: Omit<SubagentStartRequest, 'signal'> & { signal?: AbortSignal }) {
|
||||
return ctx.subagents.start(provider, { signal: request.signal ?? new AbortController().signal, ...request })
|
||||
}
|
||||
|
||||
/**
|
||||
* 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),
|
||||
@@ -62,13 +66,13 @@ describe('multi-subagent coexistence (spawn + fork on one context)', () => {
|
||||
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 spawnRun = await start(ctx, '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 forkRun = await start(ctx, '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')
|
||||
|
||||
@@ -8,7 +8,7 @@ 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 SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import * as fork from '../src/index.ts'
|
||||
@@ -17,6 +17,10 @@ import { completedTurnPrefix } from '../src/index.ts'
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
||||
|
||||
function start(ctx: Context, provider: string, request: Omit<SubagentStartRequest, 'signal'> & { signal?: AbortSignal }) {
|
||||
return ctx.subagents.start(provider, { signal: request.signal ?? new AbortController().signal, ...request })
|
||||
}
|
||||
|
||||
/** 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' } }]
|
||||
@@ -77,9 +81,9 @@ describe('dsh-subagent-fork', () => {
|
||||
if (info.provider === 'fork') childAtStart = ctx.agents.get(info.id)
|
||||
})
|
||||
|
||||
const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child q' }], parent })
|
||||
const starting = start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child q' }], parent })
|
||||
expect(childAtStart).toBeUndefined()
|
||||
await run.started
|
||||
const run = await starting
|
||||
expect(childAtStart).toBe(ctx.agents.get(run.id))
|
||||
expect(childAtStart?.id).toBe(run.id)
|
||||
|
||||
@@ -92,7 +96,7 @@ describe('dsh-subagent-fork', () => {
|
||||
// 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 run = await start(ctx, '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')
|
||||
@@ -110,7 +114,7 @@ describe('dsh-subagent-fork', () => {
|
||||
await parent.whenIdle()
|
||||
const parentPrefixLen = parent.session.events.length
|
||||
|
||||
const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child question' }], parent })
|
||||
const run = await start(ctx, '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')
|
||||
@@ -143,7 +147,7 @@ describe('dsh-subagent-fork', () => {
|
||||
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 run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child q' }], parent })
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(text(result.output)).toBe('child')
|
||||
@@ -165,7 +169,7 @@ describe('dsh-subagent-fork', () => {
|
||||
])
|
||||
parent.send([{ type: 'text', text: 'warm up' }])
|
||||
await parent.whenIdle()
|
||||
const run = ctx.subagents.start('fork', {
|
||||
const run = await start(ctx, 'fork', {
|
||||
prompt: [{ type: 'text', text: 'report structured' }],
|
||||
parent,
|
||||
outputSchema: { type: 'object', properties: { answer: { type: 'number' } }, required: ['answer'] },
|
||||
@@ -189,7 +193,7 @@ describe('dsh-subagent-fork', () => {
|
||||
parent.send([{ type: 'text', text: 'parent question' }])
|
||||
await parent.whenIdle()
|
||||
|
||||
const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child question' }], parent })
|
||||
const run = await start(ctx, '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.
|
||||
|
||||
Reference in New Issue
Block a user