Merge branch 'structured-output-subagent-seam' into worktree-dynamic-workflows

This commit is contained in:
Tianyi Cui
2026-07-07 09:19:32 +08:00
13 changed files with 50 additions and 135 deletions

View File

@@ -19,6 +19,5 @@ The seam this rides on: `CreateAgentOptions.seed` (added on `dsh-agent`, threade
| Key | Meaning | | Key | Meaning |
|---|---| |---|---|
| `providerName` | Registry name on `ctx.subagents` (default `fork`). | | `providerName` | Registry name on `ctx.subagents` (default `fork`). |
| `structuredNudgeRetries` | How many times a structured run re-prompts a child that finished cleanly without calling `structured_output` (default 1). |
See [`dsh-subagent-spawn`](../subagent-spawn/README.md) for the run lifecycle, model inheritance, and depth tracking — all shared. See [`dsh-subagent-spawn`](../subagent-spawn/README.md) for the run lifecycle, model inheritance, and depth tracking — all shared.

View File

@@ -34,20 +34,14 @@ export const name = 'subagent-fork'
// model-visible tool list) is unchanged by structured output. // model-visible tool list) is unchanged by structured output.
export const inject = ['subagents', 'agents'] export const inject = ['subagents', 'agents']
/** Config: the registry name to register the provider under, plus structured-run tuning. */ /** Config: the registry name to register the provider under. */
export interface Config { export interface Config {
/** Provider name on `ctx.subagents` (default `fork`). */ /** Provider name on `ctx.subagents` (default `fork`). */
providerName: string providerName: string
/**
* How many times a structured run re-prompts a child that finished cleanly
* without calling `structured_output` before giving up (default 1).
*/
structuredNudgeRetries: number
} }
export const Config: z<Config> = z.object({ export const Config: z<Config> = z.object({
providerName: z.string().default('fork'), providerName: z.string().default('fork'),
structuredNudgeRetries: z.natural().default(1),
}) })
/** /**
@@ -76,17 +70,12 @@ class ForkProvider implements SubagentProvider {
// Context contract: a forked child IS seeded with the parent's completed-turn prefix. // Context contract: a forked child IS seeded with the parent's completed-turn prefix.
readonly inheritsParentContext = true readonly inheritsParentContext = true
constructor( constructor(readonly name: string, private readonly ctx: Context) {}
readonly name: string,
private readonly ctx: Context,
private readonly structuredNudgeRetries: number,
) {}
start(request: SubagentStartRequest) { start(request: SubagentStartRequest) {
const seed = completedTurnPrefix(request.parent) const seed = completedTurnPrefix(request.parent)
return startInProcessRun(this.ctx, request, { return startInProcessRun(this.ctx, request, {
providerName: this.name, providerName: this.name,
structuredNudgeRetries: this.structuredNudgeRetries,
// Only pass a seed when there's a completed turn to inherit; an empty seed // 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. // is equivalent to a fresh child, so omit it to keep the session unseeded.
...seed.length > 0 ? { seed } : {}, ...seed.length > 0 ? { seed } : {},
@@ -102,5 +91,5 @@ export function apply(ctx: Context, config: Config): void {
const acquisition = acquireStructuredRuntime(ctx) const acquisition = acquireStructuredRuntime(ctx)
return () => { acquisition.release() } return () => { acquisition.release() }
}, 'subagent-fork structured runtime') }, 'subagent-fork structured runtime')
ctx.subagents.registerProvider(new ForkProvider(config.providerName, ctx, config.structuredNudgeRetries)) ctx.subagents.registerProvider(new ForkProvider(config.providerName, ctx))
} }

View File

@@ -30,8 +30,8 @@ async function setup(script: Script) {
await ctx.plugin(Invariants) await ctx.plugin(Invariants)
await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService) await ctx.plugin(SubagentService)
await ctx.plugin(Spawn, { providerName: 'spawn', structuredNudgeRetries: 1 }) await ctx.plugin(Spawn, { providerName: 'spawn' })
await ctx.plugin(fork, { providerName: 'fork', structuredNudgeRetries: 1 }) await ctx.plugin(fork, { providerName: 'fork' })
ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
return { ctx, parent } return { ctx, parent }

View File

@@ -37,7 +37,7 @@ async function setup(script: Script) {
await ctx.plugin(Invariants) await ctx.plugin(Invariants)
await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService) await ctx.plugin(SubagentService)
await ctx.plugin(fork, { providerName: 'fork', structuredNudgeRetries: 1 }) await ctx.plugin(fork, { providerName: 'fork' })
ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
return { ctx, parent } return { ctx, parent }
@@ -176,7 +176,7 @@ describe('dsh-subagent-fork', () => {
// the registries are loaded here so the runtime registers eagerly anyway. // the registries are loaded here so the runtime registers eagerly anyway.
await ctx.plugin(SystemPrompt) await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry) await ctx.plugin(ToolRegistry)
const fiber = await ctx.plugin(fork, { providerName: 'fork', structuredNudgeRetries: 1 }) const fiber = await ctx.plugin(fork, { providerName: 'fork' })
expect(ctx.subagents.list()).toEqual(['fork']) expect(ctx.subagents.list()).toEqual(['fork'])
await fiber.dispose() await fiber.dispose()
expect(ctx.subagents.list()).toEqual([]) expect(ctx.subagents.list()).toEqual([])

View File

@@ -10,14 +10,14 @@ Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (
1. computes child depth = `depthOf(parent) + 1`; if `request.maxDepth` is set and exceeded, throws `SubagentDepthError` (the `depthLimit` capability); a `request.outputSchema` is asserted against the supported subset (`assertSupportedOutputSchema` from [dsh-tools](../../core/tools/README.md)) before any child exists; 1. computes child depth = `depthOf(parent) + 1`; if `request.maxDepth` is set and exceeded, throws `SubagentDepthError` (the `depthLimit` capability); a `request.outputSchema` is asserted against the supported subset (`assertSupportedOutputSchema` from [dsh-tools](../../core/tools/README.md)) before any child exists;
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 deployment persona needs no inheritance — it is a context-wide prompt section); 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 deployment persona needs no inheritance — it is a context-wide prompt section);
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); a structured child that finished a turn CLEANLY without calling `structured_output` is re-prompted (a nudge — a fresh turn) up to `options.structuredNudgeRetries` times; 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); there is deliberately NO re-prompt for a structured child that finished cleanly without calling `structured_output` — the shortfall maps to an `error` result for the parent;
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`. A structured run surfaces the captured value as `result.structured`; a structured child that finished cleanly WITHOUT ever capturing settles `error` (a clean finish without the demanded result is a failure, not a success with a missing field). 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`. A structured run surfaces the captured value as `result.structured`; a structured child that finished cleanly WITHOUT ever capturing settles `error` (a clean finish without the demanded result is a failure, not a success with a missing field).
`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`. `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` ### `InProcessRunOptions`
`{ providerName: string; seed?: SessionEvent[]; structuredNudgeRetries: number }` — the per-backend inputs: the provider name (for error context), the optional child-session seed, and the structured-run nudge budget (REQUIRED, resolved from the backend's validated Config — the driver never fills it with a hidden default). `{ providerName: string; seed?: SessionEvent[] }` — the per-backend inputs: the provider name (for error context) and the optional child-session seed.
### Structured output: `acquireStructuredRuntime(ctx): StructuredAcquisition` ### Structured output: `acquireStructuredRuntime(ctx): StructuredAcquisition`

View File

@@ -22,7 +22,6 @@ import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools'
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
import { import {
acquireStructuredRuntime, acquireStructuredRuntime,
STRUCTURED_OUTPUT_NUDGE,
type StructuredAcquisition, type StructuredAcquisition,
} from './structured.ts' } from './structured.ts'
@@ -30,7 +29,6 @@ export {
acquireStructuredRuntime, acquireStructuredRuntime,
STRUCTURED_OUTPUT_TOOL, STRUCTURED_OUTPUT_TOOL,
STRUCTURED_OUTPUT_INSTRUCTION, STRUCTURED_OUTPUT_INSTRUCTION,
STRUCTURED_OUTPUT_NUDGE,
type StructuredAcquisition, type StructuredAcquisition,
} from './structured.ts' } from './structured.ts'
@@ -90,13 +88,6 @@ export interface InProcessRunOptions {
* parent's log (FORK), or `undefined` for a fresh child (SPAWN). * parent's log (FORK), or `undefined` for a fresh child (SPAWN).
*/ */
readonly seed?: SessionEvent[] readonly seed?: SessionEvent[]
/**
* How many times a structured run re-prompts a child that finished a turn
* cleanly WITHOUT calling `structured_output` (see the structured module).
* REQUIRED, resolved from the backend's validated Config — per the explicit-
* defaulting rule, the driver never fills it with a hidden fallback.
*/
readonly structuredNudgeRetries: number
} }
/** /**
@@ -178,7 +169,7 @@ export function startInProcessRun(
let cancelled = false let cancelled = false
// An accessor, not an inline read: `cancelled` mutates from closures (the // An accessor, not an inline read: `cancelled` mutates from closures (the
// abort listener, run.cancel), which control-flow narrowing cannot see — an // abort listener, run.cancel), which control-flow narrowing cannot see — an
// inline `!cancelled` in the nudge condition reads as always-true. // inline read at the result mapping would narrow to the initializer.
const isCancelled = (): boolean => cancelled const isCancelled = (): boolean => cancelled
const requestCancel = (reason: string): void => { const requestCancel = (reason: string): void => {
cancelled = true cancelled = true
@@ -196,28 +187,9 @@ export function startInProcessRun(
if (request.signal?.aborted) return { output: [], stopReason: 'aborted' } if (request.signal?.aborted) return { output: [], stopReason: 'aborted' }
child.send(request.prompt) child.send(request.prompt)
await child.whenIdle() await child.whenIdle()
if (structured) { // Deliberately NO re-prompt when a structured child finishes cleanly
// Nudge loop: a child that finished a turn CLEANLY without calling // without calling structured_output: readResult maps that to `error` —
// structured_output gets re-prompted, up to the backend-configured // the shortfall goes to the parent instead of buying extra model turns.
// retry count. An errored/aborted turn is not nudged — its failure is
// the honest result (a cancelled turn ends `aborted`, and a pre-turn
// cancel leaves no `turn/end` at all, so neither reads `completed`).
// `!cancelled` closes the remaining window: a cancel landing AFTER a
// clean turn end clears nothing — `child.cancel()` only kills
// queued/running work — so without it the next `send` would spend a
// fresh post-cancellation turn; the condition re-evaluates after
// every `whenIdle()`, so a mid-nudge cancel stops the loop at the
// next boundary too.
let nudges = options.structuredNudgeRetries
while (
!isCancelled() && structured.captured(child) === undefined && nudges > 0
&& lastOwnTurnEnd(child, seedLength)?.data.reason.kind === 'completed'
) {
nudges -= 1
child.send([{ type: 'text', text: STRUCTURED_OUTPUT_NUDGE }])
await child.whenIdle()
}
}
return readResult(child, seedLength, isCancelled(), structured ? { captured: structured.captured(child) } : undefined) return readResult(child, seedLength, isCancelled(), structured ? { captured: structured.captured(child) } : undefined)
} finally { } finally {
request.signal?.removeEventListener('abort', onAbort) request.signal?.removeEventListener('abort', onAbort)
@@ -241,12 +213,6 @@ export function startInProcessRun(
} }
} }
/** The child's OWN last `turn/end` event (events at or after `seedLength`), if any. */
function lastOwnTurnEnd(child: Agent, seedLength: number): SessionEvent<'turn/end'> | undefined {
return child.session.events.slice(seedLength)
.findLast((e): e is SessionEvent<'turn/end'> => e.type === 'turn/end')
}
/** /**
* Read a settled child's terminal result from its session log, scoped to the * 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 * child's OWN events (everything at or after `seedLength` — fork seeds the

View File

@@ -21,6 +21,13 @@
* returning a replacement assembly — see the waterfall composition caveat in * returning a replacement assembly — see the waterfall composition caveat in
* docs/architecture.md.) * docs/architecture.md.)
* *
* FIXME: the whole enforcement dance above exists because the tool registry
* and prompt assembly are context-global. If they become per-agent or
* per-session scoped, a structured run just registers its own schema'd tool on
* the child's scope and this module reduces to the capture tool plus the
* turn-stop — no placeholder, no final-assembly swap, no strip-for-everyone-
* else, no global-registration lifetime dance.
*
* A companion `agent/turn-continuation` listener stops a child's turn once its * A companion `agent/turn-continuation` listener stops a child's turn once its
* output is captured — without it, the loop's default "had tool calls ⇒ * output is captured — without it, the loop's default "had tool calls ⇒
* continue" buys a wasted extra model step per structured child. It is also * continue" buys a wasted extra model step per structured child. It is also
@@ -65,11 +72,6 @@ export const STRUCTURED_OUTPUT_INSTRUCTION
+ `\`${STRUCTURED_OUTPUT_TOOL}\` tool with arguments matching its parameter schema exactly. ` + `\`${STRUCTURED_OUTPUT_TOOL}\` tool with arguments matching its parameter schema exactly. `
+ 'Do not finish with a plain text answer: only the tool call counts as your result.' + 'Do not finish with a plain text answer: only the tool call counts as your result.'
/** The nudge sent when a structured child finishes cleanly without calling the tool. */
export const STRUCTURED_OUTPUT_NUDGE
= `You finished without calling \`${STRUCTURED_OUTPUT_TOOL}\`. `
+ `Call \`${STRUCTURED_OUTPUT_TOOL}\` now with your final result matching its parameter schema.`
/** One structured run's state: the schema to enforce and the captured value, once recorded. */ /** One structured run's state: the schema to enforce and the captured value, once recorded. */
interface RunState { interface RunState {
readonly schema: StructuredOutputSchema readonly schema: StructuredOutputSchema

View File

@@ -32,7 +32,7 @@ const SCHEMA: StructuredOutputSchema = {
* structured runtime at apply, exactly as shipped). The mock model script * structured runtime at apply, exactly as shipped). The mock model script
* drives the child's structured_output calls. * drives the child's structured_output calls.
*/ */
async function setup(script: Script, options?: { nudges?: number; withFork?: boolean }) { async function setup(script: Script, options?: { withFork?: boolean }) {
const ctx = new Context() const ctx = new Context()
const adapter = new MockAdapter(script) const adapter = new MockAdapter(script)
await ctx.plugin(LlmService) await ctx.plugin(LlmService)
@@ -43,9 +43,9 @@ async function setup(script: Script, options?: { nudges?: number; withFork?: boo
await ctx.plugin(Invariants) await ctx.plugin(Invariants)
await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService) await ctx.plugin(SubagentService)
const fiber = await ctx.plugin(spawn, { providerName: 'spawn', structuredNudgeRetries: options?.nudges ?? 1 }) const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
const forkFiber = options?.withFork const forkFiber = options?.withFork
? await ctx.plugin(fork, { providerName: 'fork', structuredNudgeRetries: options?.nudges ?? 1 }) ? await ctx.plugin(fork, { providerName: 'fork' })
: undefined : undefined
ctx.llm.registerAdapter(['mock'], adapter) ctx.llm.registerAdapter(['mock'], adapter)
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
@@ -215,47 +215,25 @@ describe('in-process structured output', () => {
await run.dispose() await run.dispose()
}) })
it('nudges a child that finished cleanly without calling the tool, then captures', async () => { it('a clean finish without a capture is an immediate error to the parent — deliberately NO re-prompt', async () => {
const { ctx, parent } = await setup([
textResponse('here is my answer in prose'),
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 3 }),
])
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.structured).toEqual({ answer: 3 })
expect(result.stopReason).toBe('completed')
// The nudge is a real user-visible message in the child's log.
const child = ctx.agents.get(run.id)!
const users = child.session.events.filter(e => e.type === 'user/message')
expect(users.length).toBe(2)
await run.dispose()
})
it('settles error when the nudges run out without a capture', async () => {
const { ctx, parent, adapter } = await setup([ const { ctx, parent, adapter } = await setup([
textResponse('prose only'), textResponse('here is my answer in prose'),
textResponse('still prose'), textResponse('MUST NOT BE CONSUMED'),
], { nudges: 1 }) ])
const run = ctx.subagents.start('spawn', structuredRequest(parent)) const run = ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result const result = await run.result
expect(result.stopReason).toBe('error') expect(result.stopReason).toBe('error')
expect(result.structured).toBeUndefined() expect(result.structured).toBeUndefined()
expect(adapter.requests.length).toBe(2) // Exactly one model request and one user message: no nudge turn exists.
await run.dispose()
})
it('zero nudge retries fails immediately after the first clean prose finish', async () => {
const { ctx, parent, adapter } = await setup([textResponse('prose')], { nudges: 0 })
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.stopReason).toBe('error')
expect(adapter.requests.length).toBe(1) expect(adapter.requests.length).toBe(1)
const child = ctx.agents.get(run.id)!
expect(child.session.events.filter(e => e.type === 'user/message').length).toBe(1)
await run.dispose() await run.dispose()
}) })
it('a child that errored is NOT nudged (its failure is the honest result)', async () => { it('an errored child keeps its honest error result (no capture expected)', async () => {
// Script exhaustion on the first call → the child turn errors. // Script exhaustion on the first call → the child turn errors.
const { ctx, parent, adapter } = await setup([], { nudges: 3 }) const { ctx, parent, adapter } = await setup([])
const run = ctx.subagents.start('spawn', structuredRequest(parent)) const run = ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result const result = await run.result
expect(result.stopReason).toBe('error') expect(result.stopReason).toBe('error')
@@ -263,22 +241,17 @@ describe('in-process structured output', () => {
await run.dispose() await run.dispose()
}) })
it('a cancel landing after a clean turn end stops the nudge loop: no post-cancellation turn is spent', async () => { it('a cancel landing after a clean capture-less turn settles aborted, not error', async () => {
const { ctx, parent, adapter } = await setup([textResponse('prose, no capture')], { nudges: 3 }) const { ctx, parent } = await setup([textResponse('prose, no capture')])
const run = ctx.subagents.start('spawn', structuredRequest(parent)) const run = ctx.subagents.start('spawn', structuredRequest(parent))
const child = ctx.agents.get(run.id)! const child = ctx.agents.get(run.id)!
// Cancel synchronously inside the first turn's end recording — after the // Cancel synchronously inside the turn's end recording: the cancel
// turn reads `completed`, before the nudge continuation resumes. The turn // contract outranks the schema shortfall, so the result maps to aborted.
// state alone cannot see this cancel (`child.cancel()` only clears
// queued/running work), so without the loop's own cancelled check the
// next send would spend a fresh child turn after the caller cancelled.
ctx.on('session/event', (session, event) => { ctx.on('session/event', (session, event) => {
if (session === child.session && event.type === 'turn/end') run.cancel('cancelled between turn end and nudge') if (session === child.session && event.type === 'turn/end') run.cancel('cancelled at turn end')
}) })
const result = await run.result const result = await run.result
expect(result.stopReason).toBe('aborted') expect(result.stopReason).toBe('aborted')
// Exactly one model request: the nudge turn never ran.
expect(adapter.requests.length).toBe(1)
await run.dispose() await run.dispose()
}) })

View File

@@ -51,7 +51,7 @@ describe('depthOf', () => {
describe('startInProcessRun', () => { describe('startInProcessRun', () => {
it('drives a fresh child (no seed) to completion and returns its output', async () => { it('drives a fresh child (no seed) to completion and returns its output', async () => {
const { ctx, parent } = await setup([textResponse('driver child answer')]) const { ctx, parent } = await setup([textResponse('driver child answer')])
const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'do X' }], parent }, { providerName: 'spawn', structuredNudgeRetries: 1 }) const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'do X' }], parent }, { providerName: 'spawn' })
const result = await run.result const result = await run.result
expect(result.stopReason).toBe('completed') expect(result.stopReason).toBe('completed')
expect(text(result.output)).toBe('driver child answer') expect(text(result.output)).toBe('driver child answer')
@@ -61,7 +61,7 @@ describe('startInProcessRun', () => {
it('throws SubagentDepthError when the child would exceed maxDepth', async () => { it('throws SubagentDepthError when the child would exceed maxDepth', async () => {
const { ctx, parent } = await setup([]) const { ctx, parent } = await setup([])
expect(() => startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }, { providerName: 'spawn', structuredNudgeRetries: 1 })) expect(() => startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }, { providerName: 'spawn' }))
.toThrow(SubagentDepthError) .toThrow(SubagentDepthError)
}) })
@@ -73,7 +73,7 @@ describe('startInProcessRun', () => {
parent.send([{ type: 'text', text: 'parent q' }]) parent.send([{ type: 'text', text: 'parent q' }])
await parent.whenIdle() await parent.whenIdle()
const seed = parent.session.events.slice() const seed = parent.session.events.slice()
const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'child q' }], parent }, { providerName: 'fork', structuredNudgeRetries: 1, seed }) const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'child q' }], parent }, { providerName: 'fork', seed })
const result = await run.result const result = await run.result
expect(result.stopReason).toBe('completed') expect(result.stopReason).toBe('completed')
expect(text(result.output)).toBe('seeded child reply') expect(text(result.output)).toBe('seeded child reply')

View File

@@ -6,7 +6,7 @@ The run mechanics live in the shared [`@deepseek-ai/dsh-subagent-inprocess`](../
## What it does ## What it does
`start(request)` delegates to `startInProcessRun(ctx, request, { providerName, structuredNudgeRetries })` 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). `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 ## Capabilities
@@ -17,4 +17,3 @@ The run mechanics live in the shared [`@deepseek-ai/dsh-subagent-inprocess`](../
| Key | Meaning | | Key | Meaning |
|---|---| |---|---|
| `providerName` | Registry name on `ctx.subagents` (default `spawn`). | | `providerName` | Registry name on `ctx.subagents` (default `spawn`). |
| `structuredNudgeRetries` | How many times a structured run re-prompts a child that finished cleanly without calling `structured_output` (default 1). |

View File

@@ -32,20 +32,14 @@ export const name = 'subagent-spawn'
// structured output existed. // structured output existed.
export const inject = ['subagents', 'agents'] export const inject = ['subagents', 'agents']
/** Config: the registry name to register the provider under, plus structured-run tuning. */ /** Config: the registry name to register the provider under. */
export interface Config { export interface Config {
/** Provider name on `ctx.subagents` (default `spawn`). */ /** Provider name on `ctx.subagents` (default `spawn`). */
providerName: string providerName: string
/**
* How many times a structured run re-prompts a child that finished cleanly
* without calling `structured_output` before giving up (default 1).
*/
structuredNudgeRetries: number
} }
export const Config: z<Config> = z.object({ export const Config: z<Config> = z.object({
providerName: z.string().default('spawn'), providerName: z.string().default('spawn'),
structuredNudgeRetries: z.natural().default(1),
}) })
/** /**
@@ -59,20 +53,13 @@ class SpawnProvider implements SubagentProvider {
// Context contract: a spawned child starts fresh — it never sees the parent conversation. // Context contract: a spawned child starts fresh — it never sees the parent conversation.
readonly inheritsParentContext = false readonly inheritsParentContext = false
constructor( constructor(readonly name: string, private readonly ctx: Context) {}
readonly name: string,
private readonly ctx: Context,
private readonly structuredNudgeRetries: number,
) {}
start(request: SubagentStartRequest) { start(request: SubagentStartRequest) {
// Fresh child: no seed. The shared driver mints ids, stamps cwd/lineage/ // Fresh child: no seed. The shared driver mints ids, stamps cwd/lineage/
// depth, drives the one-shot (including the structured capture/nudge loop // depth, drives the one-shot (including the structured capture when the
// when the request carries an outputSchema), and maps the result. // request carries an outputSchema), and maps the result.
return startInProcessRun(this.ctx, request, { return startInProcessRun(this.ctx, request, { providerName: this.name })
providerName: this.name,
structuredNudgeRetries: this.structuredNudgeRetries,
})
} }
} }
@@ -85,5 +72,5 @@ export function apply(ctx: Context, config: Config): void {
const acquisition = acquireStructuredRuntime(ctx) const acquisition = acquireStructuredRuntime(ctx)
return () => { acquisition.release() } return () => { acquisition.release() }
}, 'subagent-spawn structured runtime') }, 'subagent-spawn structured runtime')
ctx.subagents.registerProvider(new SpawnProvider(config.providerName, ctx, config.structuredNudgeRetries)) ctx.subagents.registerProvider(new SpawnProvider(config.providerName, ctx))
} }

View File

@@ -34,7 +34,7 @@ export async function spawnHarness(workdir: string): Promise<Context> {
await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 })
await ctx.plugin(ToolBash) await ctx.plugin(ToolBash)
await ctx.plugin(SubagentService) await ctx.plugin(SubagentService)
await ctx.plugin(Spawn, { providerName: 'spawn', structuredNudgeRetries: 1 }) await ctx.plugin(Spawn, { providerName: 'spawn' })
// The model-facing subagent tool, bound to the spawn backend. // The model-facing subagent tool, bound to the spawn backend.
await ctx.plugin(ToolSubagent, { provider: 'spawn' }) await ctx.plugin(ToolSubagent, { provider: 'spawn' })
return ctx return ctx

View File

@@ -34,7 +34,7 @@ async function setup(script: Script) {
await ctx.plugin(Invariants) await ctx.plugin(Invariants)
await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService) await ctx.plugin(SubagentService)
await ctx.plugin(spawn, { providerName: 'spawn', structuredNudgeRetries: 1 }) await ctx.plugin(spawn, { providerName: 'spawn' })
ctx.llm.registerAdapter(['mock'], adapter) ctx.llm.registerAdapter(['mock'], adapter)
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
return { ctx, parent, adapter } return { ctx, parent, adapter }
@@ -257,7 +257,7 @@ describe('dsh-subagent-spawn', () => {
// the registries are loaded here so the runtime registers eagerly anyway. // the registries are loaded here so the runtime registers eagerly anyway.
await ctx.plugin(SystemPrompt) await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry) await ctx.plugin(ToolRegistry)
const fiber = await ctx.plugin(spawn, { providerName: 'spawn', structuredNudgeRetries: 1 }) const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
expect(ctx.subagents.list()).toEqual(['spawn']) expect(ctx.subagents.list()).toEqual(['spawn'])
await fiber.dispose() await fiber.dispose()
expect(ctx.subagents.list()).toEqual([]) expect(ctx.subagents.list()).toEqual([])