Structured output on the subagent seam: schema subset, capture runtime, spawn/fork support
Carved out of #170 per review feedback — the foundation the workflow tool builds on, now standing alone on master: - dsh-tools: the structured-output JSON Schema subset (StructuredOutputSchema, assertSupportedOutputSchema, validateStructuredValue) — rejects loud outside the enforced subset, listing every violation - dsh-subagent: SubagentStartRequest.outputSchema / SubagentResult.structured become a real capability; the service rejects a schema'd request whose provider lacks it - dsh-subagent-inprocess: the shared structured runtime — one global structured_output capture tool, a prepend final-assembly listener that strips the placeholder for plain agents and swaps in the run's own schema (plus the calling instruction as a trailing section) for structured children, an agent/turn-continuation veto once captured, and the capture/nudge loop in the run driver (structuredNudgeRetries, cancellation honored mid-nudge); lifetime refcounted by backends and live runs - subagent-spawn / subagent-fork flip outputSchema: true One deliberate divergence from the #170 revision: the backends do NOT add 'tools' to their plugin inject. Doing so deferred their apply past the todo plugin, and the delegation tool mirrors provider lifecycle — so the model-visible tool order of every existing prompt changed, invalidating every recorded snapshot fixture. The runtime now gates its capture-tool registration on tools availability itself (sync when live, a scoped inject fiber when the Loader starts the backend first), keeping this PR byte-invisible to existing transcripts: all 35 snapshot scenarios pass against master's fixtures unchanged.
This commit is contained in:
@@ -12,12 +12,13 @@ The seam this rides on: `CreateAgentOptions.seed` (added on `dsh-agent`, threade
|
||||
|
||||
## Capabilities
|
||||
|
||||
`{ outputSchema: false, depthLimit: true, toolFilter: false }` — identical to spawn (the depth/model/output behavior is the shared driver's).
|
||||
`{ outputSchema: true, depthLimit: true, toolFilter: false }` — identical to spawn (the depth/model/structured-output behavior is the shared driver's).
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Meaning |
|
||||
|---|---|
|
||||
| `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.
|
||||
|
||||
@@ -25,19 +25,29 @@ import z from 'schemastery'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess'
|
||||
import { acquireStructuredRuntime, startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess'
|
||||
|
||||
export const name = 'subagent-fork'
|
||||
// `tools` is deliberately NOT injected — same rationale as subagent-spawn: the
|
||||
// structured runtime gates its capture-tool registration on `tools` itself, so
|
||||
// this backend's apply timing (and the delegation tool's position in the
|
||||
// model-visible tool list) is unchanged by structured output.
|
||||
export const inject = ['subagents', 'agents']
|
||||
|
||||
/** Config: the registry name to register the provider under. */
|
||||
/** Config: the registry name to register the provider under, plus structured-run tuning. */
|
||||
export interface Config {
|
||||
/** Provider name on `ctx.subagents` (default `fork`). */
|
||||
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({
|
||||
providerName: z.string().default('fork'),
|
||||
structuredNudgeRetries: z.natural().default(1),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -57,20 +67,26 @@ export function completedTurnPrefix(parent: Agent): SessionEvent[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* The fork provider. Supports `depthLimit`; NOT `outputSchema`/`toolFilter` this
|
||||
* cut (the service rejects a request needing either before `start` runs).
|
||||
* The fork provider. Supports `depthLimit` and `outputSchema` (via the shared
|
||||
* in-process structured runtime); NOT `toolFilter` this cut (the service
|
||||
* rejects a request needing it before `start` runs).
|
||||
*/
|
||||
class ForkProvider implements SubagentProvider {
|
||||
readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: true, toolFilter: false }
|
||||
readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: false }
|
||||
// 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,
|
||||
private readonly ctx: Context,
|
||||
private readonly structuredNudgeRetries: number,
|
||||
) {}
|
||||
|
||||
start(request: SubagentStartRequest) {
|
||||
const seed = completedTurnPrefix(request.parent)
|
||||
return startInProcessRun(this.ctx, request, {
|
||||
providerName: this.name,
|
||||
structuredNudgeRetries: this.structuredNudgeRetries,
|
||||
// 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 } : {},
|
||||
@@ -79,5 +95,12 @@ class ForkProvider implements SubagentProvider {
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
ctx.subagents.registerProvider(new ForkProvider(config.providerName, ctx))
|
||||
// Hold the structured runtime for the plugin's lifetime (see the spawn
|
||||
// backend — same two-level lifetime: backends for availability, runs for
|
||||
// mid-run survival across a backend unload).
|
||||
ctx.effect(() => {
|
||||
const acquisition = acquireStructuredRuntime(ctx)
|
||||
return () => { acquisition.release() }
|
||||
}, 'subagent-fork structured runtime')
|
||||
ctx.subagents.registerProvider(new ForkProvider(config.providerName, ctx, config.structuredNudgeRetries))
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ async function setup(script: Script) {
|
||||
await ctx.plugin(Invariants)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(Spawn, { providerName: 'spawn' })
|
||||
await ctx.plugin(fork, { providerName: 'fork' })
|
||||
await ctx.plugin(Spawn, { providerName: 'spawn', structuredNudgeRetries: 1 })
|
||||
await ctx.plugin(fork, { providerName: 'fork', structuredNudgeRetries: 1 })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
|
||||
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
|
||||
return { ctx, parent }
|
||||
|
||||
@@ -37,7 +37,7 @@ async function setup(script: Script) {
|
||||
await ctx.plugin(Invariants)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(fork, { providerName: 'fork' })
|
||||
await ctx.plugin(fork, { providerName: 'fork', structuredNudgeRetries: 1 })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
|
||||
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
|
||||
return { ctx, parent }
|
||||
@@ -161,16 +161,22 @@ describe('dsh-subagent-fork', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('advertises depthLimit but not outputSchema/toolFilter', async () => {
|
||||
it('advertises depthLimit and outputSchema but not toolFilter', async () => {
|
||||
const { ctx } = await setup([])
|
||||
expect(ctx.subagents.getProvider('fork')!.capabilities).toEqual({ outputSchema: false, depthLimit: true, toolFilter: false })
|
||||
expect(ctx.subagents.getProvider('fork')!.capabilities).toEqual({ outputSchema: true, depthLimit: true, toolFilter: false })
|
||||
})
|
||||
|
||||
it('unregisters the provider when its fiber is disposed (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const fiber = await ctx.plugin(fork, { providerName: 'fork' })
|
||||
// The backend does NOT inject 'tools' (the structured runtime gates its
|
||||
// capture-tool registration on tools availability itself, keeping backend
|
||||
// apply timing — and the delegation tool's prompt position — unchanged);
|
||||
// the registries are loaded here so the runtime registers eagerly anyway.
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
const fiber = await ctx.plugin(fork, { providerName: 'fork', structuredNudgeRetries: 1 })
|
||||
expect(ctx.subagents.list()).toEqual(['fork'])
|
||||
await fiber.dispose()
|
||||
expect(ctx.subagents.list()).toEqual([])
|
||||
|
||||
@@ -8,16 +8,27 @@ The shared **in-process subagent run driver**. A pure library (no provider, no r
|
||||
|
||||
Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`):
|
||||
|
||||
1. computes child depth = `depthOf(parent) + 1`; if `request.maxDepth` is set and exceeded, throws `SubagentDepthError` (the `depthLimit` capability);
|
||||
2. creates a child via `ctx.agents.create` with a fresh `AgentId`/`SessionId`, the parent's `cwd` + `parentSession` lineage, the optional `options.seed` (fork's completed-turn prefix; omitted for a fresh child), and `agentOptions` (the child inherits the **parent's model** by default — a child with no model can't run — overridable via `request.agentOptions.model`; the system prompt is NOT inherited);
|
||||
3. drives the one-shot: `child.send(prompt)` then `await child.whenIdle()` (ordering matters — `send` enqueues synchronously, so `whenIdle` observes the queued work and resolves on the child's `running → idle` transition, never before the turn starts);
|
||||
4. reads the result, scoped to the child's OWN events (everything at or after `seedLength`, so a seeded child that produced no message of its own never returns the seeded parent's last message): the last `assistant/message` content (deep-cloned — the log is frozen) and the last `turn/end.reason` mapped to a `SubagentStopReason`.
|
||||
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);
|
||||
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;
|
||||
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`.
|
||||
|
||||
### `InProcessRunOptions`
|
||||
|
||||
`{ providerName: string; seed?: SessionEvent[] }` — the per-backend inputs: the provider name (for error context) and the optional child-session seed.
|
||||
`{ 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).
|
||||
|
||||
### Structured output: `acquireStructuredRuntime(ctx): StructuredAcquisition`
|
||||
|
||||
The mechanism behind `outputSchema` for in-process children. One globally registered `structured_output` capture tool (its registered parameters are a placeholder) plus two listeners, registered once per root context and shared by every holder:
|
||||
|
||||
- an `agent/request` waterfall listener registered `prepend: true` that post-processes `await next()` — **final-request enforcement**: the request that hits the wire never carries `structured_output` for an agent without a structured run, and for one that has it always carries the run's OWN schema (as the tool's `parameters`) plus the calling instruction appended to its `system` text (the demand travels with the tool — `AgentOptions` has no per-agent prompt field to carry it). Per-agent shaping lives here because the tool registry and prompt assembly are context-global while schemas differ per concurrent child; cooperative mutate-then-`next()` would not survive a downstream listener returning a replacement request.
|
||||
- an `agent/turn-continuation` listener (also `prepend: true` — an earlier-registered force-continue listener returning without `next()` must not decide the turn before the veto runs) that stops a child's turn once its output is captured, so a successful capture doesn't buy a wasted extra model step.
|
||||
|
||||
The capture tool validates each call against the run's schema (`validateStructuredValue`) — violations become an `INVALID_ARGS` isError result the model retries in-turn; a valid call records the value.
|
||||
|
||||
Lifetime is refcounted with two kinds of holder: each backend acquires for its plugin lifetime (`apply`), and each structured RUN holds its own acquisition from start to settle — so unregistration can never precede a live run's settle, and the runtime disposes only when the last backend AND the last run are gone. `release()` is idempotent per acquisition.
|
||||
|
||||
### `depthOf(agent): number`
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -35,6 +37,8 @@
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-fork": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
|
||||
@@ -18,7 +18,21 @@ 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 { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools'
|
||||
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
|
||||
import {
|
||||
acquireStructuredRuntime,
|
||||
STRUCTURED_OUTPUT_NUDGE,
|
||||
type StructuredAcquisition,
|
||||
} from './structured.ts'
|
||||
|
||||
export {
|
||||
acquireStructuredRuntime,
|
||||
STRUCTURED_OUTPUT_TOOL,
|
||||
STRUCTURED_OUTPUT_INSTRUCTION,
|
||||
STRUCTURED_OUTPUT_NUDGE,
|
||||
type StructuredAcquisition,
|
||||
} from './structured.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-agent' {
|
||||
interface AgentOptions {
|
||||
@@ -76,6 +90,13 @@ export interface InProcessRunOptions {
|
||||
* parent's log (FORK), or `undefined` for a fresh child (SPAWN).
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,6 +119,10 @@ export function startInProcessRun(
|
||||
if (request.maxDepth !== undefined && childDepth > request.maxDepth) {
|
||||
throw new SubagentDepthError(childDepth, request.maxDepth)
|
||||
}
|
||||
// Assert the schema subset BEFORE any child exists (the service has already
|
||||
// capability-gated; this rejects a schema outside the enforced subset loud).
|
||||
const schema = request.outputSchema
|
||||
if (schema !== undefined) assertSupportedOutputSchema(schema)
|
||||
|
||||
const childId = AgentId(randomUUID())
|
||||
// The child's OWN events begin after the seed (fork seeds the parent's
|
||||
@@ -109,13 +134,20 @@ export function startInProcessRun(
|
||||
// Inherit the parent's model by default (a child with no model cannot run);
|
||||
// an explicit `request.agentOptions.model` overrides it. The persona needs
|
||||
// no inheritance: the deployment persona is a context-wide prompt section,
|
||||
// so parent and child render the same one.
|
||||
// so parent and child render the same one. A structured run's
|
||||
// structured_output instruction is NOT prompt state either — the structured
|
||||
// runtime's final-request listener appends it per request (see structured.ts).
|
||||
const agentOptions: AgentOptions = {
|
||||
...request.parent.options.model !== undefined ? { model: request.parent.options.model } : {},
|
||||
...request.agentOptions,
|
||||
subagentDepth: childDepth,
|
||||
}
|
||||
|
||||
// The structured runtime is held for the WHOLE run (acquired before the child
|
||||
// exists, released when the result settles), so a backend hot-reload mid-run
|
||||
// cannot unregister the capture tool out from under this live child.
|
||||
const structured: StructuredAcquisition | undefined = schema !== undefined ? acquireStructuredRuntime(ctx) : undefined
|
||||
|
||||
const handle: AgentHandle = ctx.agents.create({
|
||||
agentId: childId,
|
||||
sessionId: SessionId(randomUUID()),
|
||||
@@ -130,6 +162,7 @@ export function startInProcessRun(
|
||||
agentOptions,
|
||||
})
|
||||
const child = handle.agent
|
||||
if (structured && schema !== undefined) structured.attach(child, schema)
|
||||
|
||||
// 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).
|
||||
@@ -138,6 +171,10 @@ export function startInProcessRun(
|
||||
// `turn/end` is logged — settles as `aborted` (honoring the cancel contract)
|
||||
// rather than falling through to the no-turn `error` mapping.
|
||||
let cancelled = false
|
||||
// An accessor, not an inline read: `cancelled` mutates from closures (the
|
||||
// abort listener, run.cancel), which control-flow narrowing cannot see — an
|
||||
// inline `!cancelled` in the nudge condition reads as always-true.
|
||||
const isCancelled = (): boolean => cancelled
|
||||
const requestCancel = (reason: string): void => {
|
||||
cancelled = true
|
||||
child.cancel(reason)
|
||||
@@ -154,9 +191,35 @@ export function startInProcessRun(
|
||||
if (request.signal?.aborted) return { output: [], stopReason: 'aborted' }
|
||||
child.send(request.prompt)
|
||||
await child.whenIdle()
|
||||
return readResult(child, seedLength, cancelled)
|
||||
if (structured) {
|
||||
// Nudge loop: a child that finished a turn CLEANLY without calling
|
||||
// structured_output gets re-prompted, up to the backend-configured
|
||||
// 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)
|
||||
} finally {
|
||||
request.signal?.removeEventListener('abort', onAbort)
|
||||
if (structured) {
|
||||
structured.detach(child)
|
||||
structured.release()
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -173,6 +236,12 @@ 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
|
||||
* child's OWN events (everything at or after `seedLength` — fork seeds the
|
||||
@@ -184,12 +253,32 @@ export function startInProcessRun(
|
||||
* logged (a cancel landed in the pre-turn window, before any turn ran), the
|
||||
* run settles `aborted` per the {@link SubagentRun.cancel} contract rather than
|
||||
* the generic no-turn `error`.
|
||||
*
|
||||
* A structured run (`structured` present) additionally reports the captured
|
||||
* value on {@link SubagentResult.structured}. A structured child that finished
|
||||
* CLEANLY without ever capturing (the nudges ran out) settles `error` — a clean
|
||||
* finish without the demanded structured result is a failure, not a success
|
||||
* with a missing field; a non-`completed` reason keeps its own honest mapping.
|
||||
*/
|
||||
function readResult(child: Agent, seedLength: number, cancelled: boolean): SubagentResult {
|
||||
function readResult(
|
||||
child: Agent,
|
||||
seedLength: number,
|
||||
cancelled: boolean,
|
||||
structured?: { captured?: { value: unknown } | undefined },
|
||||
): SubagentResult {
|
||||
const own = child.session.events.slice(seedLength)
|
||||
const lastMessage = own.findLast((e): e is SessionEvent<'assistant/message'> => e.type === 'assistant/message')
|
||||
const lastEnd = own.findLast((e): e is SessionEvent<'turn/end'> => e.type === 'turn/end')
|
||||
const output: ContentBlock[] = lastMessage ? structuredClone(lastMessage.data.content) : []
|
||||
if (lastEnd === undefined && cancelled) return { output, stopReason: 'aborted' }
|
||||
return { output, stopReason: toStopReason(lastEnd?.data.reason) }
|
||||
const stopReason: SubagentStopReason = lastEnd === undefined && cancelled
|
||||
? 'aborted'
|
||||
: toStopReason(lastEnd?.data.reason)
|
||||
if (structured) {
|
||||
if (structured.captured) return { output, structured: structured.captured.value, stopReason }
|
||||
// No capture on a cleanly-completed turn: an ERROR when the run was left
|
||||
// to finish (the nudges ran out), but ABORTED when a cancel is why the
|
||||
// nudging stopped — the cancel contract outranks the schema shortfall.
|
||||
if (stopReason === 'completed') return { output, stopReason: cancelled ? 'aborted' : 'error' }
|
||||
}
|
||||
return { output, stopReason }
|
||||
}
|
||||
|
||||
239
packages/subagent/subagent-inprocess/src/structured.ts
Normal file
239
packages/subagent/subagent-inprocess/src/structured.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Structured-output support for the in-process subagent backends: the mechanism
|
||||
* behind `SubagentStartRequest.outputSchema` for children that run as agents on
|
||||
* the same context.
|
||||
*
|
||||
* The model-facing surface is one globally registered `structured_output` tool
|
||||
* whose REGISTERED parameters are a placeholder — the real schema is per run.
|
||||
* Because the tool registry and prompt assembly are context-global while
|
||||
* schemas differ per child (two concurrent structured runs may carry different
|
||||
* schemas), per-agent shaping happens on the `system-prompt/assemble`
|
||||
* waterfall with a `prepend: true` listener that post-processes `await next()`
|
||||
* — FINAL-ASSEMBLY enforcement: whatever downstream listeners mutated or
|
||||
* replaced, the assembly the loop renders never carries `structured_output`
|
||||
* for an agent without a structured run, and for one that has it always
|
||||
* carries the run's OWN schema plus a trailing
|
||||
* {@link STRUCTURED_OUTPUT_INSTRUCTION} section (the demand travels with the
|
||||
* tool). The loop logs what the assembly produced as the request header, so
|
||||
* the injection is a reconstructable fact of the session log, never a
|
||||
* wire-only mutation (the reconstructability RFC).
|
||||
* (Cooperative mutate-then-`next()` would not survive a downstream listener
|
||||
* returning a replacement assembly — see the waterfall composition caveat in
|
||||
* docs/architecture.md.)
|
||||
*
|
||||
* 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 ⇒
|
||||
* continue" buys a wasted extra model step per structured child. It is also
|
||||
* `prepend: true`: the veto must run before any earlier-registered listener
|
||||
* that could short-circuit the chain into a forced continue.
|
||||
*
|
||||
* Lifetime is refcounted with two kinds of holder: each backend acquires for
|
||||
* its plugin lifetime (so the tool exists before any run), and each structured
|
||||
* RUN acquires from start to settle (so a backend hot-reload mid-run cannot
|
||||
* unregister the capture tool out from under a live child). Registrations are
|
||||
* effects on the ROOT context — their natural upper bound is app teardown — and
|
||||
* the refcount disposes them when the last holder releases.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent-inprocess/structured
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContinuationDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type { AssembleContext, PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/** The model-facing tool name a structured child must call to finish. */
|
||||
export const STRUCTURED_OUTPUT_TOOL = 'structured_output'
|
||||
|
||||
/**
|
||||
* The instruction the assembly listener appends to a structured child's
|
||||
* system prompt as a trailing section on every assembly. Per-assembly state,
|
||||
* NOT agent prompt state: `AgentOptions` has no prompt field (the persona is
|
||||
* deployment config on the system-prompt plugin), so the same final-assembly
|
||||
* enforcement that injects the schema'd tool carries the instruction that
|
||||
* demands calling it.
|
||||
*/
|
||||
export const STRUCTURED_OUTPUT_INSTRUCTION
|
||||
= 'When you have your final answer, you MUST report it by calling the '
|
||||
+ `\`${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.'
|
||||
|
||||
/** 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. */
|
||||
interface RunState {
|
||||
readonly schema: StructuredOutputSchema
|
||||
captured?: { value: unknown }
|
||||
}
|
||||
|
||||
/** The per-root-context runtime: run states plus the shared registrations. */
|
||||
interface StructuredRuntime {
|
||||
refs: number
|
||||
readonly states: WeakMap<Agent, RunState>
|
||||
readonly disposers: (() => void)[]
|
||||
}
|
||||
|
||||
/** One root context ⇒ one runtime (multi-app test isolation). */
|
||||
const runtimes = new WeakMap<Context, StructuredRuntime>()
|
||||
|
||||
/**
|
||||
* One holder's handle on the shared structured runtime. `release()` is
|
||||
* idempotent per acquisition; the runtime's registrations are disposed when the
|
||||
* LAST holder (backend plugin or live run) releases.
|
||||
*/
|
||||
export interface StructuredAcquisition {
|
||||
/** Enforce `schema` on `agent`'s requests and start capturing its `structured_output` call. */
|
||||
attach(agent: Agent, schema: StructuredOutputSchema): void
|
||||
/** The captured value, once the child called the tool with valid arguments. */
|
||||
captured(agent: Agent): { value: unknown } | undefined
|
||||
/** Stop enforcing/capturing for `agent` (WeakMap-backed; safe to call twice). */
|
||||
detach(agent: Agent): void
|
||||
/** Drop this holder's reference (idempotent); the last release unregisters everything. */
|
||||
release(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire the per-root-context structured runtime, registering the capture tool
|
||||
* and the two waterfall listeners on the FIRST acquisition. See the module doc
|
||||
* for the enforcement and lifetime design.
|
||||
* @param ctx - any context of the app; the runtime keys off `ctx.root`.
|
||||
* @returns this holder's handle (attach/captured/detach + idempotent release).
|
||||
*/
|
||||
export function acquireStructuredRuntime(ctx: Context): StructuredAcquisition {
|
||||
const root: Context = ctx.root
|
||||
let runtime = runtimes.get(root)
|
||||
if (!runtime) {
|
||||
runtime = { refs: 0, states: new WeakMap(), disposers: [] }
|
||||
runtimes.set(root, runtime)
|
||||
registerRuntime(root, runtime)
|
||||
}
|
||||
runtime.refs += 1
|
||||
|
||||
let released = false
|
||||
return {
|
||||
attach(agent: Agent, schema: StructuredOutputSchema): void {
|
||||
runtime.states.set(agent, { schema })
|
||||
},
|
||||
captured(agent: Agent): { value: unknown } | undefined {
|
||||
return runtime.states.get(agent)?.captured
|
||||
},
|
||||
detach(agent: Agent): void {
|
||||
runtime.states.delete(agent)
|
||||
},
|
||||
release(): void {
|
||||
if (released) return
|
||||
released = true
|
||||
runtime.refs -= 1
|
||||
if (runtime.refs > 0) return
|
||||
runtimes.delete(root)
|
||||
for (const dispose of runtime.disposers.splice(0)) dispose()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the capture tool + the two listeners on the root context (first acquire). */
|
||||
function registerRuntime(root: Context, runtime: StructuredRuntime): void {
|
||||
// The registered parameters are a PLACEHOLDER: the request listener below
|
||||
// swaps in the run's real schema per child, and strips the tool entirely for
|
||||
// every agent without a structured run — so this shape is never model-visible.
|
||||
//
|
||||
// Registration does NOT ride on the acquiring backend's plugin-level
|
||||
// `inject`: a backend that waited on `tools` would apply later than it did
|
||||
// before this module existed, shifting when its PROVIDER registers — and the
|
||||
// delegation tool mirrors provider lifecycle, so that shift would reorder
|
||||
// the model-visible tool list of every existing prompt. Instead the capture
|
||||
// tool registers synchronously when `tools` is already live (the common
|
||||
// case), and through a scoped inject fiber when the Loader happens to start
|
||||
// the backend first. Either way the registration lands on root and is
|
||||
// disposed by the runtime's refcount; disposing the fiber also covers the
|
||||
// never-activated case.
|
||||
let disposeTool: (() => void) | undefined
|
||||
const registerCapture = (tools: Context['tools']): void => {
|
||||
disposeTool = tools.register({
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
description:
|
||||
'Report your final structured result. Call this exactly once, when your answer is complete; '
|
||||
+ 'the arguments must match this tool\'s parameter schema exactly.',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]> {
|
||||
const state = exec.agent ? runtime.states.get(exec.agent) : undefined
|
||||
if (!state) {
|
||||
// Reachable only if a non-structured agent somehow calls the tool (the
|
||||
// request listener strips it, so the model never sees it) — fail loud
|
||||
// rather than capture into nowhere.
|
||||
throw new Error(`${STRUCTURED_OUTPUT_TOOL} is only available to subagents started with an output schema`)
|
||||
}
|
||||
const violations = validateStructuredValue(state.schema, args)
|
||||
// ToolArgsError → isError result with INVALID_ARGS: the model retries
|
||||
// within the same turn, exactly like a schema-validated defineTool call.
|
||||
if (violations.length > 0) throw new ToolArgsError(violations)
|
||||
state.captured = { value: args }
|
||||
return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }])
|
||||
},
|
||||
})
|
||||
}
|
||||
const liveTools = root.get('tools')
|
||||
const toolsFiber = liveTools ? undefined : root.inject(['tools'], (childCtx: Context) => {
|
||||
registerCapture(childCtx.root.tools)
|
||||
})
|
||||
if (liveTools) registerCapture(liveTools)
|
||||
runtime.disposers.push(() => {
|
||||
disposeTool?.()
|
||||
void toolsFiber?.dispose()
|
||||
})
|
||||
|
||||
// FINAL-ASSEMBLY enforcement (prepend: true = first registered = OUTERMOST
|
||||
// wrapper): post-process whatever the downstream listeners and the registry
|
||||
// produced, so a downstream listener returning a replacement assembly cannot
|
||||
// leak the tool to other agents or erase the child's schema. The loop logs
|
||||
// the rendered assembly as the step's request header, so the swap is
|
||||
// reconstructable log state, never a wire-only mutation.
|
||||
runtime.disposers.push(root.on('system-prompt/assemble', async function (
|
||||
this: unknown, _assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>,
|
||||
): Promise<PromptAssembly> {
|
||||
const final = await next()
|
||||
const state = context.agent ? runtime.states.get(context.agent) : undefined
|
||||
if (state) {
|
||||
const schemaEntry: ToolSchema = {
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
description:
|
||||
'Report your final structured result. Call this exactly once, when your answer is complete; '
|
||||
+ 'the arguments must match this tool\'s parameter schema exactly.',
|
||||
// ToolSchema.parameters is the wire-level JSON Schema object; the
|
||||
// asserted subset type is structurally exactly that.
|
||||
parameters: state.schema as unknown as Record<string, unknown>,
|
||||
}
|
||||
final.tools = [...final.tools.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL), schemaEntry]
|
||||
// The demand travels WITH the tool: a trailing section in the
|
||||
// tool-guidance order band, appended after next() so it renders last
|
||||
// (renderPrompt joins in array order).
|
||||
final.sections = [...final.sections, { name: `tool:${STRUCTURED_OUTPUT_TOOL}`, order: 190, text: STRUCTURED_OUTPUT_INSTRUCTION }]
|
||||
return final
|
||||
}
|
||||
// No structured run: strip the placeholder so it is never model-visible.
|
||||
// An empty tools array canonicalizes to an absent header/wire field
|
||||
// (canonicalHeader pins empty ≡ absent), so no re-shaping is needed here.
|
||||
final.tools = final.tools.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL)
|
||||
return final
|
||||
}, { prepend: true }))
|
||||
|
||||
// Stop a structured child's turn once its output is captured: the default
|
||||
// "had tool calls ⇒ continue" would otherwise buy a wasted extra model step
|
||||
// after every successful capture. `prepend: true` puts the veto OUTERMOST —
|
||||
// an earlier-registered listener that short-circuits the chain (a goal-style
|
||||
// force-continue returning without `next()`) would otherwise decide the turn
|
||||
// before this listener ever ran, and no downstream decision may resurrect a
|
||||
// structured turn that is already finished.
|
||||
runtime.disposers.push(root.on('agent/turn-continuation', function (
|
||||
this: unknown, agent: Agent, _turn: number, _decision: ContinuationDecision, next: () => Promise<ContinuationDecision>,
|
||||
): Promise<ContinuationDecision> {
|
||||
if (runtime.states.get(agent)?.captured) return Promise.resolve({ action: 'stop' })
|
||||
return next()
|
||||
}, { prepend: true }))
|
||||
}
|
||||
485
packages/subagent/subagent-inprocess/tests/structured.spec.ts
Normal file
485
packages/subagent/subagent-inprocess/tests/structured.spec.ts
Normal file
@@ -0,0 +1,485 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { type GenerateOptions } 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 type { Agent, ContinuationDecision } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import * as spawn from '@deepseek-ai/dsh-subagent-spawn'
|
||||
import * as fork from '@deepseek-ai/dsh-subagent-fork'
|
||||
import {
|
||||
acquireStructuredRuntime,
|
||||
STRUCTURED_OUTPUT_INSTRUCTION,
|
||||
STRUCTURED_OUTPUT_TOOL,
|
||||
} from '../src/structured.ts'
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
||||
|
||||
const SCHEMA: StructuredOutputSchema = {
|
||||
type: 'object',
|
||||
properties: { answer: { type: 'number' }, note: { type: 'string' } },
|
||||
required: ['answer'],
|
||||
}
|
||||
|
||||
/**
|
||||
* Real loop + scripted mock model + the REAL spawn backend (which acquires the
|
||||
* structured runtime at apply, exactly as shipped). The mock model script
|
||||
* drives the child's structured_output calls.
|
||||
*/
|
||||
async function setup(script: Script, options?: { nudges?: number; withFork?: boolean }) {
|
||||
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)
|
||||
const fiber = await ctx.plugin(spawn, { providerName: 'spawn', structuredNudgeRetries: options?.nudges ?? 1 })
|
||||
const forkFiber = options?.withFork
|
||||
? await ctx.plugin(fork, { providerName: 'fork', structuredNudgeRetries: options?.nudges ?? 1 })
|
||||
: undefined
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
|
||||
return { ctx, parent, adapter, fiber, forkFiber }
|
||||
}
|
||||
|
||||
function structuredRequest(parent: SubagentStartRequest['parent'], extra?: Partial<SubagentStartRequest>): SubagentStartRequest {
|
||||
return { prompt: [{ type: 'text', text: 'produce the answer' }], parent, outputSchema: SCHEMA, ...extra }
|
||||
}
|
||||
|
||||
/** The tool names of one recorded model request. */
|
||||
function toolNames(request: GenerateOptions): string[] {
|
||||
return (request.tools ?? []).map(tool => tool.name)
|
||||
}
|
||||
|
||||
describe('in-process structured output', () => {
|
||||
it('captures a valid structured_output call and surfaces result.structured', async () => {
|
||||
const { ctx, parent } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42, note: 'done' }),
|
||||
])
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(result.structured).toEqual({ answer: 42, note: 'done' })
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('stops the turn after a successful capture — no extra model step is spent', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
|
||||
textResponse('MUST NOT BE CONSUMED'),
|
||||
])
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
await run.result
|
||||
// Default continuation would run a second step after the tool call; the
|
||||
// structured runtime's turn-continuation veto stops the turn instead.
|
||||
expect(adapter.requests.length).toBe(1)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('the captured-turn veto is prepend: an EARLIER force-continue listener cannot short-circuit it', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
// Registered BEFORE the structured runtime exists — without prepend, this
|
||||
// goal-style listener would decide the turn first (returning WITHOUT
|
||||
// calling next()) and the veto would never run.
|
||||
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'continue' }))
|
||||
const acquisition = acquireStructuredRuntime(ctx)
|
||||
const agent = { id: AgentId('structured-child') } as unknown as Agent
|
||||
acquisition.attach(agent, SCHEMA)
|
||||
const captured = await ctx.tools.execute({
|
||||
callId: 'call-1' as never,
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
arguments: { answer: 1 },
|
||||
agent,
|
||||
})
|
||||
expect(captured.isError).toBeFalsy()
|
||||
const decision = await ctx.waterfall(
|
||||
'agent/turn-continuation', agent, 1,
|
||||
{ action: 'continue' },
|
||||
() => Promise.resolve<ContinuationDecision>({ action: 'continue' }),
|
||||
)
|
||||
expect(decision).toEqual({ action: 'stop' })
|
||||
acquisition.detach(agent)
|
||||
acquisition.release()
|
||||
})
|
||||
|
||||
it('an invalid call gets an INVALID_ARGS isError result and the model retries in-turn', async () => {
|
||||
const { ctx, parent } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 'not-a-number' }),
|
||||
toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
|
||||
])
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const result = await run.result
|
||||
expect(result.structured).toEqual({ answer: 7 })
|
||||
expect(result.stopReason).toBe('completed')
|
||||
// The child's log carries the isError tool/result for the invalid call.
|
||||
const child = ctx.agents.get(run.id)!
|
||||
const results = child.session.events.filter(e => e.type === 'tool/result')
|
||||
expect(results.length).toBe(2)
|
||||
expect((results[0]!.data as { isError?: boolean }).isError).toBe(true)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('nudges a child that finished cleanly without calling the tool, then captures', 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([
|
||||
textResponse('prose only'),
|
||||
textResponse('still prose'),
|
||||
], { nudges: 1 })
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.structured).toBeUndefined()
|
||||
expect(adapter.requests.length).toBe(2)
|
||||
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)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('a child that errored is NOT nudged (its failure is the honest result)', async () => {
|
||||
// Script exhaustion on the first call → the child turn errors.
|
||||
const { ctx, parent, adapter } = await setup([], { nudges: 3 })
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(adapter.requests.length).toBe(1)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('a cancel landing after a clean turn end stops the nudge loop: no post-cancellation turn is spent', async () => {
|
||||
const { ctx, parent, adapter } = await setup([textResponse('prose, no capture')], { nudges: 3 })
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const child = ctx.agents.get(run.id)!
|
||||
// Cancel synchronously inside the first turn's end recording — after the
|
||||
// turn reads `completed`, before the nudge continuation resumes. The turn
|
||||
// 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) => {
|
||||
if (session === child.session && event.type === 'turn/end') run.cancel('cancelled between turn end and nudge')
|
||||
})
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('aborted')
|
||||
// Exactly one model request: the nudge turn never ran.
|
||||
expect(adapter.requests.length).toBe(1)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('rejects a schema outside the subset loud, before any child exists', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
expect(() => ctx.subagents.start('spawn', structuredRequest(parent, {
|
||||
outputSchema: { type: 'object', oneOf: [] } as unknown as StructuredOutputSchema,
|
||||
}))).toThrow(/unsupported output schema/)
|
||||
expect(ctx.agents.get(AgentId('parent'))).toBeDefined()
|
||||
})
|
||||
|
||||
it('appends the structured instruction to the child REQUEST\'s system text (base prompt preserved)', async () => {
|
||||
const { ctx, parent, adapter } = await setup([toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 })])
|
||||
// A context-wide section stands in for the deployment persona: the
|
||||
// instruction must APPEND to whatever the prompt pipeline assembled, not
|
||||
// replace it (AgentOptions has no prompt field — the instruction is
|
||||
// per-request wire state added by the final-request listener).
|
||||
ctx.systemPrompt.section({ name: 'test:persona', order: 10, text: 'You are a counter.' })
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
await run.result
|
||||
const childRequest = adapter.requests.at(-1)!
|
||||
expect(childRequest.system).toContain('You are a counter.')
|
||||
expect(childRequest.system!.endsWith(STRUCTURED_OUTPUT_INSTRUCTION)).toBe(true)
|
||||
expect(childRequest.system!.indexOf(STRUCTURED_OUTPUT_INSTRUCTION)).toBeGreaterThan(0)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('the instruction rides ONLY structured requests: appended for the child, absent for a plain agent', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
textResponse('parent answer'),
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
|
||||
])
|
||||
parent.send([{ type: 'text', text: 'hello' }])
|
||||
await parent.whenIdle()
|
||||
expect(adapter.requests[0]!.system ?? '').not.toContain(STRUCTURED_OUTPUT_INSTRUCTION)
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
await run.result
|
||||
// The loop always assembles a base prompt (the harness identity section),
|
||||
// so the instruction APPENDS — never replaces.
|
||||
const childSystem = adapter.requests.at(-1)!.system!
|
||||
expect(childSystem.endsWith(STRUCTURED_OUTPUT_INSTRUCTION)).toBe(true)
|
||||
expect(childSystem.length).toBeGreaterThan(STRUCTURED_OUTPUT_INSTRUCTION.length)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
describe('final-request enforcement (the prepend agent/request listener)', () => {
|
||||
it('a structured child sees structured_output with ITS schema; a plain agent never sees the tool', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
// Parent turn (a plain agent): must NOT see the tool.
|
||||
textResponse('parent answer'),
|
||||
// Child turn: must see it, with the run's schema.
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42 }),
|
||||
])
|
||||
parent.send([{ type: 'text', text: 'hello' }])
|
||||
await parent.whenIdle()
|
||||
expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL)
|
||||
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
await run.result
|
||||
const childRequest = adapter.requests[1]!
|
||||
expect(toolNames(childRequest)).toContain(STRUCTURED_OUTPUT_TOOL)
|
||||
const entry = childRequest.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!
|
||||
expect(entry.parameters).toEqual(SCHEMA)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('two concurrent structured children each see their OWN schema', async () => {
|
||||
const otherSchema: StructuredOutputSchema = {
|
||||
type: 'object',
|
||||
properties: { verdict: { type: 'string', enum: ['real', 'bogus'] } },
|
||||
required: ['verdict'],
|
||||
}
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
(options: GenerateOptions) => {
|
||||
// Answer with whatever schema this child was given — proves each
|
||||
// request carried the right one regardless of scheduling order.
|
||||
const entry = options.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!
|
||||
const args = 'verdict' in (entry.parameters.properties as Record<string, unknown>)
|
||||
? { verdict: 'real' }
|
||||
: { answer: 1 }
|
||||
return toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, args)
|
||||
},
|
||||
(options: GenerateOptions) => {
|
||||
const entry = options.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!
|
||||
const args = 'verdict' in (entry.parameters.properties as Record<string, unknown>)
|
||||
? { verdict: 'real' }
|
||||
: { answer: 1 }
|
||||
return toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, args)
|
||||
},
|
||||
])
|
||||
const runA = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const runB = ctx.subagents.start('spawn', structuredRequest(parent, { outputSchema: otherSchema }))
|
||||
const [a, b] = await Promise.all([runA.result, runB.result])
|
||||
expect(a.structured).toEqual({ answer: 1 })
|
||||
expect(b.structured).toEqual({ verdict: 'real' })
|
||||
const schemas = adapter.requests.map(request =>
|
||||
request.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!.parameters)
|
||||
expect(schemas).toContainEqual(SCHEMA)
|
||||
expect(schemas).toContainEqual(otherSchema)
|
||||
await runA.dispose()
|
||||
await runB.dispose()
|
||||
})
|
||||
|
||||
it('wins against a downstream listener that REPLACES the assembly object', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }),
|
||||
])
|
||||
// A downstream (non-prepend) listener that returns a brand-new assembly —
|
||||
// the composition caveat that erases cooperative mutations. Registered
|
||||
// AFTER the runtime's prepend listener, so it runs INSIDE it.
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const replaced = await next()
|
||||
return { sections: [...replaced.sections], tools: [...replaced.tools], variables: { ...replaced.variables } }
|
||||
})
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const result = await run.result
|
||||
expect(result.structured).toEqual({ answer: 5 })
|
||||
const entry = adapter.requests[0]!.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)
|
||||
expect(entry).toBeDefined()
|
||||
expect(entry!.parameters).toEqual(SCHEMA)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('a non-structured agent request keeps tools ABSENT when it had none (no tools: [] materialized)', async () => {
|
||||
const { parent, adapter } = await setup([
|
||||
// The registry contributes the placeholder via prompt assembly, so
|
||||
// tools is an array in the raw request — but after stripping the
|
||||
// placeholder (its ONLY entry), the field must not be re-added as a
|
||||
// different shape.
|
||||
textResponse('plain'),
|
||||
])
|
||||
parent.send([{ type: 'text', text: 'q' }])
|
||||
await parent.whenIdle()
|
||||
const request = adapter.requests[0]!
|
||||
expect(toolNames(request)).not.toContain(STRUCTURED_OUTPUT_TOOL)
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
it('shapes a bare assembly on the waterfall: no-agent context strips the placeholder; a structured agent gains schema + trailing instruction section', async () => {
|
||||
// Drive ctx.systemPrompt.assemble directly — the enforcement listener
|
||||
// must tolerate a context with NO agent (a bare diagnostic assemble)
|
||||
// and shape a structured agent's assembly on the same path the loop
|
||||
// renders and logs as the request header.
|
||||
const { ctx, parent } = await setup([])
|
||||
const bare = await ctx.systemPrompt.assemble({})
|
||||
expect(bare.tools.map(tool => tool.name)).not.toContain(STRUCTURED_OUTPUT_TOOL)
|
||||
|
||||
const acquisition = acquireStructuredRuntime(ctx)
|
||||
acquisition.attach(parent, SCHEMA)
|
||||
const shaped = await ctx.systemPrompt.assemble({ agent: parent })
|
||||
expect(shaped.tools.map(tool => tool.name)).toContain(STRUCTURED_OUTPUT_TOOL)
|
||||
expect(shaped.tools.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!.parameters).toEqual(SCHEMA)
|
||||
// The demand travels with the tool: the instruction renders LAST
|
||||
// (appended post-next(); renderPrompt joins in array order).
|
||||
expect(shaped.sections.at(-1)).toMatchObject({ name: `tool:${STRUCTURED_OUTPUT_TOOL}`, text: STRUCTURED_OUTPUT_INSTRUCTION })
|
||||
acquisition.detach(parent)
|
||||
acquisition.release()
|
||||
})
|
||||
})
|
||||
|
||||
describe('runtime lifetime (refcount: backends + live runs)', () => {
|
||||
it('registers the capture tool while a backend is loaded and unregisters when the last unloads', async () => {
|
||||
const { ctx, fiber, forkFiber } = await setup([], { withFork: true })
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined()
|
||||
await fiber.dispose()
|
||||
// fork still holds a reference.
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined()
|
||||
await forkFiber!.dispose()
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a live run-level acquisition keeps the runtime registered after EVERY backend unloads', async () => {
|
||||
// Simulates the run-holder half of the two-level lifetime: a structured
|
||||
// run acquires at start and releases at settle, so registration ordering
|
||||
// is settle-then-unregister even if all backends unload first. (A real
|
||||
// in-process child dies WITH its backend's fiber — the acquisition's
|
||||
// observable job is this ordering, which a manual holder pins directly.)
|
||||
const { ctx, fiber, forkFiber } = await setup([], { withFork: true })
|
||||
const runHolder = acquireStructuredRuntime(ctx)
|
||||
await fiber.dispose()
|
||||
await forkFiber!.dispose()
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined()
|
||||
runHolder.release()
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a structured run releases its acquisition when it settles (backend unload mid-run)', async () => {
|
||||
const { ctx, parent, fiber } = await setup(['hang'])
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
// 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 acquisition on the way out.
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
await fiber.dispose()
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('error')
|
||||
// Both holders (backend + run) released — nothing keeps the runtime now.
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('fork children capture structured output through the same runtime', async () => {
|
||||
const { ctx, parent } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 9 }),
|
||||
], { withFork: true })
|
||||
const run = ctx.subagents.start('fork', structuredRequest(parent))
|
||||
const result = await run.result
|
||||
expect(result.structured).toEqual({ answer: 9 })
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('acquisition release is idempotent (double release cannot underflow the refcount)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
const first = acquireStructuredRuntime(ctx)
|
||||
const second = acquireStructuredRuntime(ctx)
|
||||
first.release()
|
||||
first.release()
|
||||
// The second holder still keeps the tool registered.
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined()
|
||||
second.release()
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('registers the capture tool through the scoped fiber when tools loads after the acquisition', async () => {
|
||||
// The Loader starts sibling plugins concurrently, so a backend can
|
||||
// acquire the runtime before dsh-tools has applied. The capture tool
|
||||
// must then register as soon as `tools` exists — via the inject fiber,
|
||||
// not by deferring the backend (which would reorder the prompt's tools).
|
||||
const ctx = new Context()
|
||||
const acquisition = acquireStructuredRuntime(ctx)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
// Fiber activation completes asynchronously after the service appears.
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined()
|
||||
acquisition.release()
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('releasing before tools ever loads disposes the pending fiber without registering', async () => {
|
||||
const ctx = new Context()
|
||||
const acquisition = acquireStructuredRuntime(ctx)
|
||||
acquisition.release()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
// The disposed fiber never fires: nothing registers after the fact.
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('attach/captured/detach manage per-agent state through the acquisition surface', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const acquisition = acquireStructuredRuntime(ctx)
|
||||
expect(acquisition.captured(parent)).toBeUndefined()
|
||||
acquisition.attach(parent, SCHEMA)
|
||||
expect(acquisition.captured(parent)).toBeUndefined()
|
||||
acquisition.detach(parent)
|
||||
acquisition.detach(parent)
|
||||
acquisition.release()
|
||||
// The backend still holds its own reference from setup().
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('a direct structured_output call from an agent WITHOUT a structured run is an isError', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const result = await ctx.tools.execute({
|
||||
callId: 'x' as never,
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
arguments: { answer: 1 },
|
||||
agent: parent,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({ type: 'text' })
|
||||
})
|
||||
|
||||
it('a structured_output call with NO calling agent at all is an isError', async () => {
|
||||
const { ctx } = await setup([])
|
||||
const result = await ctx.tools.execute({
|
||||
callId: 'x' as never,
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
arguments: { answer: 1 },
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -51,7 +51,7 @@ describe('depthOf', () => {
|
||||
describe('startInProcessRun', () => {
|
||||
it('drives a fresh child (no seed) to completion and returns its output', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('driver child answer')])
|
||||
const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'do X' }], parent }, { providerName: 'spawn' })
|
||||
const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'do X' }], parent }, { providerName: 'spawn', structuredNudgeRetries: 1 })
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(text(result.output)).toBe('driver child answer')
|
||||
@@ -61,7 +61,7 @@ describe('startInProcessRun', () => {
|
||||
|
||||
it('throws SubagentDepthError when the child would exceed maxDepth', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
expect(() => startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }, { providerName: 'spawn' }))
|
||||
expect(() => startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }, { providerName: 'spawn', structuredNudgeRetries: 1 }))
|
||||
.toThrow(SubagentDepthError)
|
||||
})
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('startInProcessRun', () => {
|
||||
parent.send([{ type: 'text', text: 'parent q' }])
|
||||
await parent.whenIdle()
|
||||
const seed = parent.session.events.slice()
|
||||
const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'child q' }], parent }, { providerName: 'fork', seed })
|
||||
const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'child q' }], parent }, { providerName: 'fork', structuredNudgeRetries: 1, seed })
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(text(result.output)).toBe('seeded child reply')
|
||||
|
||||
@@ -25,6 +25,12 @@
|
||||
},
|
||||
{
|
||||
"path": "../subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,14 +6,15 @@ The run mechanics live in the shared [`@deepseek-ai/dsh-subagent-inprocess`](../
|
||||
|
||||
## What it does
|
||||
|
||||
`start(request)` delegates to `startInProcessRun(ctx, request, { providerName })` with no seed: a fresh child agent with the parent's `cwd`/`parentSession` lineage and (by default) the parent's model. See the [driver README](../subagent-inprocess/README.md) for the full lifecycle (depth check, one-shot drive, result read, dispose).
|
||||
`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).
|
||||
|
||||
## 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).
|
||||
`{ outputSchema: true, depthLimit: true, toolFilter: false }`. It constructs the child, so it enforces a recursion cap, and it supports structured output via the driver's shared [structured runtime](../subagent-inprocess/README.md) (the backend acquires it for its plugin lifetime; each structured run holds its own acquisition until it settles). Tool-scoping is deferred (the service rejects a request needing it before `start` runs).
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Meaning |
|
||||
|---|---|
|
||||
| `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). |
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
* ({@link startInProcessRun}); this backend just passes NO seed (a fresh
|
||||
* child). The fork backend is an independent peer over the same driver.
|
||||
*
|
||||
* Structured output (`outputSchema`) is supported via the driver's shared
|
||||
* structured runtime: the backend acquires it for its plugin lifetime (so the
|
||||
* capture tool and request-shaping listeners exist before any run), and each
|
||||
* structured run holds its own acquisition until it settles.
|
||||
*
|
||||
* Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent-spawn
|
||||
@@ -17,40 +22,68 @@
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess'
|
||||
import { acquireStructuredRuntime, startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess'
|
||||
|
||||
export const name = 'subagent-spawn'
|
||||
// `tools` is deliberately NOT injected: the structured runtime gates its own
|
||||
// capture-tool registration on `tools` availability internally, so this
|
||||
// backend's apply timing — and with it the provider-mirroring delegation
|
||||
// tool's position in the model-visible tool list — stays what it was before
|
||||
// structured output existed.
|
||||
export const inject = ['subagents', 'agents']
|
||||
|
||||
/** Config: the registry name to register the provider under. */
|
||||
/** Config: the registry name to register the provider under, plus structured-run tuning. */
|
||||
export interface Config {
|
||||
/** Provider name on `ctx.subagents` (default `spawn`). */
|
||||
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({
|
||||
providerName: z.string().default('spawn'),
|
||||
structuredNudgeRetries: z.natural().default(1),
|
||||
})
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* enforce a recursion cap) and `outputSchema` (via the shared in-process
|
||||
* structured runtime); NOT `toolFilter` in this cut — a request that needs it
|
||||
* is rejected by the service before `start` runs.
|
||||
*/
|
||||
class SpawnProvider implements SubagentProvider {
|
||||
readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: true, toolFilter: false }
|
||||
readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: false }
|
||||
// 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,
|
||||
private readonly ctx: Context,
|
||||
private readonly structuredNudgeRetries: number,
|
||||
) {}
|
||||
|
||||
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 })
|
||||
// depth, drives the one-shot (including the structured capture/nudge loop
|
||||
// when the request carries an outputSchema), and maps the result.
|
||||
return startInProcessRun(this.ctx, request, {
|
||||
providerName: this.name,
|
||||
structuredNudgeRetries: this.structuredNudgeRetries,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
ctx.subagents.registerProvider(new SpawnProvider(config.providerName, ctx))
|
||||
// Hold the structured runtime for the plugin's lifetime, so the capture tool
|
||||
// and its request-shaping listeners are registered before the first
|
||||
// structured run and torn down when the last backend unloads (live runs hold
|
||||
// their own acquisitions, so an unload mid-run cannot strand a child).
|
||||
ctx.effect(() => {
|
||||
const acquisition = acquireStructuredRuntime(ctx)
|
||||
return () => { acquisition.release() }
|
||||
}, 'subagent-spawn structured runtime')
|
||||
ctx.subagents.registerProvider(new SpawnProvider(config.providerName, ctx, config.structuredNudgeRetries))
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export async function spawnHarness(workdir: string): Promise<Context> {
|
||||
await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 })
|
||||
await ctx.plugin(ToolBash)
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(Spawn, { providerName: 'spawn' })
|
||||
await ctx.plugin(Spawn, { providerName: 'spawn', structuredNudgeRetries: 1 })
|
||||
// The model-facing subagent tool, bound to the spawn backend.
|
||||
await ctx.plugin(ToolSubagent, { provider: 'spawn' })
|
||||
return ctx
|
||||
|
||||
@@ -34,7 +34,7 @@ async function setup(script: Script) {
|
||||
await ctx.plugin(Invariants)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(spawn, { providerName: 'spawn' })
|
||||
await ctx.plugin(spawn, { providerName: 'spawn', structuredNudgeRetries: 1 })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
|
||||
return { ctx, parent, adapter }
|
||||
@@ -241,17 +241,23 @@ describe('dsh-subagent-spawn', () => {
|
||||
await parentHandle.dispose()
|
||||
})
|
||||
|
||||
it('advertises depthLimit but not outputSchema/toolFilter', async () => {
|
||||
it('advertises depthLimit and outputSchema but not toolFilter', async () => {
|
||||
const { ctx } = await setup([])
|
||||
const provider = ctx.subagents.getProvider('spawn')!
|
||||
expect(provider.capabilities).toEqual({ outputSchema: false, depthLimit: true, toolFilter: false })
|
||||
expect(provider.capabilities).toEqual({ outputSchema: true, 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' })
|
||||
// The backend does NOT inject 'tools' (the structured runtime gates its
|
||||
// capture-tool registration on tools availability itself, keeping backend
|
||||
// apply timing — and the delegation tool's prompt position — unchanged);
|
||||
// the registries are loaded here so the runtime registers eagerly anyway.
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
const fiber = await ctx.plugin(spawn, { providerName: 'spawn', structuredNudgeRetries: 1 })
|
||||
expect(ctx.subagents.list()).toEqual(['spawn'])
|
||||
await fiber.dispose()
|
||||
expect(ctx.subagents.list()).toEqual([])
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import type { Agent, AgentId, AgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SchemaSpec } from '@deepseek-ai/dsh-tools'
|
||||
import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/**
|
||||
* Which START-TIME features a provider supports. Checked by the service
|
||||
@@ -56,12 +56,16 @@ export interface SubagentStartRequest {
|
||||
/** Per-child agent options (model, system prompt). */
|
||||
agentOptions?: AgentOptions
|
||||
/**
|
||||
* Optional structured-output schema. When set AND the provider's
|
||||
* {@link SubagentCapabilities.outputSchema} is `true`, the child's final
|
||||
* answer is shaped to this schema and surfaced as {@link SubagentResult.structured}.
|
||||
* Optional structured-output schema — an object-rooted JSON Schema within the
|
||||
* enforced subset (see `assertSupportedOutputSchema` in dsh-tools; a schema
|
||||
* outside the subset is rejected loud at start). When set AND the provider's
|
||||
* {@link SubagentCapabilities.outputSchema} is `true`, the child is driven to
|
||||
* report a value matching this schema, surfaced as
|
||||
* {@link SubagentResult.structured}. The schema must be plain host-realm JSON
|
||||
* data — a caller holding foreign-realm data materializes it first.
|
||||
* Requesting it against a provider that lacks the capability is rejected at start.
|
||||
*/
|
||||
outputSchema?: SchemaSpec
|
||||
outputSchema?: StructuredOutputSchema
|
||||
/**
|
||||
* Optional recursion cap (max delegation depth below this child). Requires
|
||||
* {@link SubagentCapabilities.depthLimit}; rejected at start otherwise.
|
||||
|
||||
@@ -178,7 +178,7 @@ describe('SubagentService', () => {
|
||||
|
||||
describe('start-time capability validation (fail loud, before any child)', () => {
|
||||
it.each([
|
||||
{ field: 'outputSchema', request: baseRequest({ outputSchema: { x: { type: 'string' } } }) },
|
||||
{ field: 'outputSchema', request: baseRequest({ outputSchema: { type: 'object', properties: { x: { type: 'string' } } } }) },
|
||||
{ field: 'maxDepth', request: baseRequest({ maxDepth: 2 }) },
|
||||
{ field: 'toolFilter', request: baseRequest({ toolFilter: { deny: ['bash'] } }) },
|
||||
])('rejects $field against a provider that lacks the capability — before start() runs', ({ request }) => {
|
||||
@@ -203,7 +203,7 @@ describe('SubagentService', () => {
|
||||
await ctx.plugin(SubagentService)
|
||||
const provider = new StubProvider('strong', ALL_CAPS)
|
||||
ctx.subagents.registerProvider(provider)
|
||||
ctx.subagents.start('strong', baseRequest({ outputSchema: { x: { type: 'string' } }, maxDepth: 1 }))
|
||||
ctx.subagents.start('strong', baseRequest({ outputSchema: { type: 'object', properties: { x: { type: 'string' } } }, maxDepth: 1 }))
|
||||
expect(provider.startCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user