Merge remote-tracking branch 'origin/master' into agent-request-messages

docs/core-data-structures/core.md: combined master's canonical tool-order
wording (#196) with this branch's request-advice envelope + wire-order
paragraphs.
This commit is contained in:
Yichen Jiang
2026-07-08 10:24:35 +08:00
77 changed files with 2645 additions and 162 deletions

View File

@@ -35,11 +35,11 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement
```ts
import type { Config } from '@deepseek-ai/dsh-agent-core'
// { agents?, persona? } — the schema is z.intersect([AgentLoop.Config, SystemPrompt.Config]),
// { agents?, persona?, toolOrder? } — the schema is z.intersect([AgentLoop.Config, SystemPrompt.Config]),
// so validation and defaulting can never drift from the owners'.
```
The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — and `persona` to `dsh-system-prompt` (default `''`), the deployment's persona section. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure.
The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` to `dsh-system-prompt` (default `''`), the deployment's persona section — and `toolOrder` to `dsh-system-prompt` (absent — lexicographic), the explicit model-facing tool order. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure.
## Why a code bundle, not a shared YAML include

View File

@@ -59,17 +59,20 @@ export const name = 'agent-core'
/**
* Bundle config: each field forwarded verbatim to the child that owns it —
* `agents` to the agent loop (an app that pre-creates no agents, like the ACP
* bridge, simply omits it), `persona` to the system-prompt plugin (the
* deployment's persona section). Both are optional INPUT here because each
* owner's schema supplies the default (`[]` / `''`); the schema is the
* INTERSECTION of the owners' own schemas, so validation and defaulting can
* never drift from them.
* bridge, simply omits it), `persona` and `toolOrder` to the system-prompt
* plugin (the deployment's persona section and the explicit model-facing tool
* order). Every field is optional INPUT here because each owner's schema
* supplies the default (`[]` / `''` / absent — lexicographic); the schema is
* the INTERSECTION of the owners' own schemas, so validation and defaulting
* can never drift from them.
*/
export interface Config {
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
agents?: AgentLoopConfig['agents']
/** The deployment persona (see dsh-system-prompt's `Config`). */
persona?: SystemPromptConfig['persona']
/** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */
toolOrder?: SystemPromptConfig['toolOrder']
}
/** Intersect the owners' schemas so validation + defaulting stay identical. */
@@ -78,11 +81,11 @@ export const Config = z.intersect([AgentLoop.Config, SystemPrompt.Config]) as un
/**
* Load the spine. Each `ctx.plugin(...)` mounts one child of the bundle fiber;
* `agent-loop` receives the forwarded `agents` list and `system-prompt` the
* forwarded `persona`. Load order is irrelevant (cordis pends each fiber on
* its `inject` until the services it needs exist), but the listing mirrors the
* dependency layering for readability: the LLM vocabulary and core registries
* first, then the dev tripwire and the bash tool consumer, then the loop that
* drives them.
* forwarded `persona` and `toolOrder`. Load order is irrelevant (cordis pends
* each fiber on its `inject` until the services it needs exist), but the
* listing mirrors the dependency layering for readability: the LLM vocabulary
* and core registries first, then the dev tripwire and the bash tool consumer,
* then the loop that drives them.
*/
export function apply(ctx: Context, config: Config): void {
ctx.plugin(Timer)
@@ -91,8 +94,13 @@ export function apply(ctx: Context, config: Config): void {
// The forwarded fields are validated + defaulted by this bundle's intersected
// schema before apply runs, so the ?? fallbacks only narrow the
// optional-input TYPES — they mirror the owners' schema defaults, never
// introduce different ones.
ctx.plugin(SystemPrompt, { persona: config.persona ?? '' })
// introduce different ones. toolOrder has no owner-supplied default value —
// ABSENT means "lexicographic order" — so it is forwarded conditionally
// rather than via ??.
ctx.plugin(SystemPrompt, {
persona: config.persona ?? '',
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
})
ctx.plugin(ToolRegistry)
ctx.plugin(AgentRegistry)
ctx.plugin(invariants)

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import * as agentCore from '../src/index.ts'
import { AgentId } from '@deepseek-ai/dsh-agent'
@@ -67,6 +68,23 @@ describe('dsh-agent-core bundle', () => {
await ctx.fiber.dispose()
})
it('forwards toolOrder to the system-prompt assembly', async () => {
const ctx = await mount({ toolOrder: ['zulu', TOOL_ORDER_REST] })
// The bundle's own bash tools pend on the absent `ctx.bash` executor in
// this providerless mount, so register two plain tools to order.
for (const name of ['alpha', 'zulu']) {
ctx.get('tools')!.register({
name,
description: name,
parameters: {},
execute: async () => [],
})
}
const assembly = await ctx.get('systemPrompt')!.assemble()
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha'])
await ctx.fiber.dispose()
})
it('re-exports the loop config schema as its own', () => {
expect(agentCore.Config).toBeDefined()
expect(agentCore.name).toBe('agent-core')

View File

@@ -0,0 +1,117 @@
/**
* Loop-level tool-order determinism: the request/header event — and therefore
* the frozen request the adapter receives — carries the assembly's canonical
* tool order (system-prompt's `toolOrder` config, or lexicographic name
* order), regardless of the order tool plugins happened to register in.
* Registration order is a plugin-load artifact (concurrent dynamic imports
* race), so nothing downstream of the registry may depend on it.
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { foldRequestHeader } from '@deepseek-ai/dsh-session'
import SystemPrompt, { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['toolOrder']) {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: 'stable base', ...toolOrder !== undefined ? { toolOrder } : {} })
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
dispose()
resolve()
}
})
})
}
function registerNamed(ctx: Context, name: string) {
ctx.tools.register(defineTool({
name,
description: `the ${name} tool`,
parameters: {},
async execute() {
return [{ type: 'text', text: name }]
},
}))
}
/** Run one text-only turn and return the harness context + agent. */
async function runTurn(registrationOrder: string[], toolOrder?: SystemPromptConfig['toolOrder']) {
const adapter = new MockAdapter([textResponse('done')])
const ctx = await harness(adapter, toolOrder)
for (const name of registrationOrder) registerNamed(ctx, name)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
return { ctx, agent, adapter }
}
describe('loop-level canonical tool order', () => {
it('logs the request/header with tools in canonical order, not registration order', async () => {
const { agent, adapter } = await runTurn(['zulu', 'alpha', 'mike'])
const header = foldRequestHeader(agent.session.events)
expect(header?.tools?.map(tool => tool.name)).toEqual(['alpha', 'mike', 'zulu'])
// The dispatched request is built FROM the logged header (whose tools the
// assembly already canonicalized) and reaches the adapter deep-frozen —
// the marker the reconstruction invariant keys on.
expect(adapter.requests[0]?.tools?.map(tool => tool.name)).toEqual(['alpha', 'mike', 'zulu'])
expect(Object.isFrozen(adapter.requests[0])).toBe(true)
expect(adapter.requests[0]?.sessionId).toBe(agent.session.id)
})
it('produces the same header order for any registration order', async () => {
const first = await runTurn(['alpha', 'mike', 'zulu'])
const second = await runTurn(['zulu', 'mike', 'alpha'])
const names = (run: typeof first) => foldRequestHeader(run.agent.session.events)?.tools?.map(tool => tool.name)
expect(names(first)).toEqual(['alpha', 'mike', 'zulu'])
expect(names(second)).toEqual(names(first))
})
it('honors a configured toolOrder in the logged header and the dispatched request', async () => {
const { agent, adapter } = await runTurn(['alpha', 'zulu', 'mike'], ['zulu', TOOL_ORDER_REST])
const header = foldRequestHeader(agent.session.events)
expect(header?.tools?.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'mike'])
expect(adapter.requests[0]?.tools?.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'mike'])
expect(Object.isFrozen(adapter.requests[0])).toBe(true)
})
it('fails the turn — no model request — when toolOrder names an unregistered tool', async () => {
// The assemble rejection escapes to runTurn's outer catch: the open turn
// closes with an `error` reason (agent/error mirrors it), no step opens,
// no request/header is logged, the adapter never sees a request, and the
// agent returns to idle — a misconfigured deployment fails every turn
// deterministically instead of silently reordering nothing.
const adapter = new MockAdapter([textResponse('never sent')])
const ctx = await harness(adapter, ['ghost', TOOL_ORDER_REST])
registerNamed(ctx, 'alpha')
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
expect(errors.map(e => e.message)).toEqual(['toolOrder lists unregistered tool "ghost"; registered tools: alpha'])
expect(foldRequestHeader(agent.session.events)).toBeUndefined()
const end = agent.session.events.find(e => e.type === 'turn/end')
expect(end?.type === 'turn/end' && end.data.reason).toMatchObject({ kind: 'error', step: 1 })
// The turn is balanced (turn/start → turn/end) with no step events inside.
expect(agent.session.events.some(e => e.type === 'step/start')).toBe(false)
})
})

View File

@@ -7,15 +7,16 @@ System prompt assembly registry. Plugins contribute ordered text sections, tool-
| Key | Default | Meaning |
|---|---|---|
| `persona` | `''` | The deployment persona: the ONE deployment-authored prompt fragment, rendered as the order-0 `deployment:persona` section and shared by every agent in the context (subagents included). A template — complete `{{…}}` groups are interpreted strictly against the registered variables (the shipped loop registers `{{model}}`/`{{cwd}}`), with no escape syntax for literal braces yet. Empty ⇒ the section is dropped at render. |
| `toolOrder` | — | Explicit model-facing tool order, as a list of `ToolSchema.name`s with one `'<unlisted-tools>'` rest entry (`TOOL_ORDER_REST`): listed tools take their listed position, unlisted tools land at the rest entry in lexicographic name order. Absent ⇒ plain lexicographic name order. Applied to the collected tools BEFORE the `system-prompt/assemble` waterfall — like the sections' `order` sort, it canonicalizes what the registry contributed (registration order is a plugin-load artifact), and a waterfall listener that mutates the list owns the determinism of what it emits. Misconfiguration fails loud: a list without exactly one rest entry, or with duplicates, throws at load; a listed name with no registered tool rejects every `assemble()`; a tool provider returning the reserved rest-entry name also rejects. Under the shipped loop the turn fails before any model request. Why a central list and not per-plugin weights: [Explicit model-facing tool order](../../../docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md). |
## Service: `SystemPrompt` (ctx key: `systemPrompt`)
### Public API
- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. Duplicate names throw. Disposed with the calling fiber.
- `ctx.systemPrompt.tools(provider: () => ToolSchema[]): () => void` Contribute tool schemas (evaluated at each assembly). Disposed with the calling fiber.
- `ctx.systemPrompt.tools(provider: () => ToolSchema[]): () => void` Contribute tool schemas (evaluated at each assembly). A provider must not return a schema named `TOOL_ORDER_REST`; that name is reserved for `toolOrder`'s rest entry. Disposed with the calling fiber.
- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void` Contribute a prompt variable, referenced from section text as `{{name}}`. Duplicate or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber.
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller. Runs through the `system-prompt/assemble` waterfall.
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller. Runs through the `system-prompt/assemble` waterfall. Rejects when a configured `toolOrder` names a tool no provider contributed, or when a provider returns the reserved rest-entry name.
### Events

View File

@@ -88,7 +88,8 @@ export interface AssembledSection {
*
* Tool schemas are part of the assembly by design: "what the model is told it
* can do" is one coherent thing managed here, even though adapters transmit
* `tools` as a separate wire field rather than prompt text.
* `tools` as a separate wire field rather than prompt text. They arrive in
* the canonical model-facing order (see {@link Config.toolOrder}).
*
* `variables` carries every registered prompt variable resolved against this
* assembly's context — key present means registered, `undefined` value means
@@ -110,6 +111,70 @@ const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/
/** A complete `{{...}}` reference group at the scan position (validated after). */
const GROUP_AT = /^\{\{([^{}]*)\}\}/
/**
* The rest entry for {@link Config.toolOrder}: the position where registered
* tools not named in the list are inserted (in lexicographic name order).
* Reserved: collected tool schemas using this name are rejected before
* ordering, so the marker can never collide with a real model-facing tool.
*/
export const TOOL_ORDER_REST = '<unlisted-tools>'
/**
* Validate a configured tool-order list's shape at service construction:
* the {@link TOOL_ORDER_REST} rest entry exactly once, no duplicate names.
* Returns the list (or undefined when unconfigured); throws otherwise,
* failing the service at load — a bad order config must never reach an
* assembly. Whether every listed name matches a registered tool is checked
* at each assembly instead ({@link orderTools}): tool plugins register after
* this service constructs, so the tool set does not exist yet here.
*/
function validateToolOrder(toolOrder: string[] | undefined): string[] | undefined {
if (toolOrder === undefined) return undefined
const seen = new Set<string>()
for (const name of toolOrder) {
if (seen.has(name)) throw new Error(`toolOrder lists "${name}" more than once`)
seen.add(name)
}
if (!seen.has(TOOL_ORDER_REST)) {
throw new Error(`toolOrder must contain the "${TOOL_ORDER_REST}" rest entry (where unlisted tools are inserted)`)
}
return toolOrder
}
/**
* Order collected tool schemas by the validated policy: with no configured
* list, plain lexicographic name order; with one, listed names take their
* listed position and every unlisted tool lands at the
* {@link TOOL_ORDER_REST} rest entry in lexicographic name order. A listed
* name with no collected tool throws — misconfiguration fails loud, and this
* is the earliest moment the registered tool set exists to check against
* (tool plugins register after the service constructs, so load time is too
* early): the assembly rejects, failing the caller's turn before any model
* request. Never drops a tool, and both sorts are stable, so tools sharing a
* name keep their collection order.
*/
function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined): ToolSchema[] {
const reserved = tools.find(tool => tool.name === TOOL_ORDER_REST)
if (reserved !== undefined) {
throw new Error(`tool provider returned reserved tool name "${TOOL_ORDER_REST}" (reserved for toolOrder's rest entry)`)
}
if (toolOrder === undefined) return tools.sort(compareToolNames)
const registered = new Set(tools.map(tool => tool.name))
const unknown = toolOrder.filter(name => name !== TOOL_ORDER_REST && !registered.has(name))
if (unknown.length > 0) {
throw new Error(`toolOrder lists unregistered tool${unknown.length > 1 ? 's' : ''} ${unknown.map(name => `"${name}"`).join(', ')}; registered tools: ${[...registered].sort().join(', ') || '(none)'}`)
}
const listed = new Set(toolOrder)
const rest = tools.filter(tool => !listed.has(tool.name)).sort(compareToolNames)
return toolOrder.flatMap(name =>
name === TOOL_ORDER_REST ? rest : tools.filter(tool => tool.name === name))
}
/** Lexicographic (code-unit) name comparison — locale-independent, so the order is identical on every machine. */
function compareToolNames(a: ToolSchema, b: ToolSchema): number {
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
}
/** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.persona} for its contract). */
export interface Config {
/**
@@ -125,6 +190,29 @@ export interface Config {
* deployment opens with the harness identity alone.
*/
persona?: string
/**
* Explicit model-facing tool order, as a list of `ToolSchema.name`s: listed
* tools take their listed position, and tools absent from the list are
* inserted at the {@link TOOL_ORDER_REST} (`'<unlisted-tools>'`) entry in
* lexicographic name order. A configured list must contain the rest entry
* exactly once, no duplicate names, and no name without a registered tool —
* a misconfigured order blocks work instead of silently reaching a model
* request: shape violations throw at load, and an unregistered name rejects
* every assembly. `TOOL_ORDER_REST` is reserved for the list marker and may
* not be a collected tool name; such a provider output also rejects the
* assembly. The single assembly-time validation rejects either failure
* before any model request — the earliest moment the registered tool set
* exists to check against, since tool plugins register after this service
* constructs. When omitted, tools are ordered lexicographically by name.
* Applied to the tools
* {@link SystemPrompt.assemble} collects, BEFORE the
* `system-prompt/assemble` waterfall — like the sections' `order` sort, it
* canonicalizes what the registry contributed (registration order is a
* plugin-load artifact); a waterfall listener that mutates the tool list
* owns the determinism of what it emits. Rationale (and why not per-plugin
* weights): docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md.
*/
toolOrder?: string[]
}
/**
@@ -203,14 +291,23 @@ function interpolate(section: AssembledSection, variables: Record<string, string
export class SystemPrompt extends Service {
static Config: z<Config> = z.object({
persona: z.string().default(''),
// A schemastery array defaults to [] when omitted, but an omitted
// toolOrder must stay absent ("lexicographic order"), not become an
// explicitly-configured empty list (which is invalid — it lacks the
// rest entry). Forcing the default to undefined keeps the key out of the
// validated config; the cast is needed because .default() expects the
// array type.
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
})
private sections: PromptSection[] = []
private toolProviders: (() => ToolSchema[])[] = []
private variableProviders = new Map<string, (context: AssembleContext) => string | undefined>()
private readonly toolOrder: string[] | undefined
constructor(ctx: Context, public config: Config) {
super(ctx, 'systemPrompt')
this.toolOrder = validateToolOrder(config.toolOrder)
// The harness-owned openers. They live HERE (not on the loop plugin) so a
// deployment that swaps in a different loop keeps them: the identity is a
// harness fact stated ahead of everything, and the persona is the
@@ -266,7 +363,10 @@ export class SystemPrompt extends Service {
/**
* Contribute a tool-schema provider that is evaluated at each assembly
* call (so it can reflect the live registry state). The provider is
* removed when the calling fiber is disposed. Emits `system-prompt/change`.
* removed when the calling fiber is disposed. A provider must not return a
* schema named {@link TOOL_ORDER_REST}; that name is reserved for
* {@link Config.toolOrder}'s rest entry and rejects the assembly. Emits
* `system-prompt/change`.
* @param provider - evaluated at every {@link assemble} for fresh schemas.
* @returns the disposer that removes the provider.
*/
@@ -323,19 +423,28 @@ export class SystemPrompt extends Service {
/**
* Assemble the current prompt for one caller: section texts are resolved
* against `context` and sorted by order, tools collected from all
* providers, and every registered variable resolved against `context` into
* `assembly.variables`. Tool schemas are deep-cloned because adapters and
* request waterfalls may mutate schema objects. Runs through the
* `system-prompt/assemble` waterfall, giving listeners the opportunity to
* mutate or replace the assembly before it reaches the model. Await the
* result before reading the assembly values — waterfall listeners may be
* async. Interpolation happens later, in {@link renderPrompt}.
* against `context` and sorted by order, tools collected from all providers
* and put in the canonical model-facing order ({@link Config.toolOrder}, or
* lexicographic name order when unconfigured — provider registration order
* is a plugin-load artifact and never reaches the assembly; a configured
* order naming a tool no provider contributed rejects the assembly), and every
* registered variable resolved against `context` into `assembly.variables`.
* Tool schemas are deep-cloned because adapters and request waterfalls may
* mutate schema objects. Runs through the `system-prompt/assemble`
* waterfall, giving listeners the opportunity to mutate or replace the
* assembly before it reaches the model — like the sections' `order` sort,
* tool canonicalization happens on the initial assembly, and a listener
* owns the determinism of whatever it emits. Await the result before
* reading the assembly values — waterfall listeners may be async.
* Interpolation happens later, in {@link renderPrompt}.
* @param context - what this assembly is for (defaults to an empty context;
* see {@link AssembleContext}).
* @returns the assembly after the waterfall has run.
*/
assemble(context: AssembleContext = {}): Promise<PromptAssembly> {
// async so the misconfigured-toolOrder throw in orderTools surfaces as a
// rejection: a Promise-returning method must not throw synchronously
// (`assemble().catch(...)` would miss it).
async assemble(context: AssembleContext = {}): Promise<PromptAssembly> {
const variables: Record<string, string | undefined> = {}
for (const [name, provider] of this.variableProviders) {
variables[name] = provider(context)
@@ -348,8 +457,10 @@ export class SystemPrompt extends Service {
text: typeof section.text === 'function' ? section.text(context) : section.text,
}))
.sort((a, b) => a.order - b.order),
tools: this.toolProviders.flatMap(provider =>
provider().map(tool => ({ ...tool, parameters: structuredClone(tool.parameters) }))),
tools: orderTools(
this.toolProviders.flatMap(provider =>
provider().map(tool => ({ ...tool, parameters: structuredClone(tool.parameters) }))),
this.toolOrder),
variables,
}
return this.ctx.waterfall(this, 'system-prompt/assemble', assembly, context, () => Promise.resolve(assembly))

View File

@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import SystemPrompt, { PromptAssembly, TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
function tool(name: string, description = name): ToolSchema {
return { name, description, parameters: { type: 'object', properties: {} } }
}
async function mount(config: { persona?: string; toolOrder?: string[] } = {}): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt, config)
return ctx
}
function names(assembly: PromptAssembly): string[] {
return assembly.tools.map(t => t.name)
}
describe('SystemPrompt tool order', () => {
// The ONE place the public constant's value is pinned; everything else
// (tests and deployment configs alike) references TOOL_ORDER_REST.
it('exports the rest entry as "<unlisted-tools>"', () => {
expect(TOOL_ORDER_REST).toBe('<unlisted-tools>')
})
it('assembles tools in lexicographic name order when no toolOrder is configured', async () => {
const ctx = await mount()
ctx.systemPrompt.tools(() => [tool('charlie'), tool('alpha')])
ctx.systemPrompt.tools(() => [tool('bravo')])
expect(names(await ctx.systemPrompt.assemble())).toEqual(['alpha', 'bravo', 'charlie'])
})
it('assembles the same order regardless of provider registration order', async () => {
const forward = await mount()
forward.systemPrompt.tools(() => [tool('alpha')])
forward.systemPrompt.tools(() => [tool('zulu')])
const backward = await mount()
backward.systemPrompt.tools(() => [tool('zulu')])
backward.systemPrompt.tools(() => [tool('alpha')])
expect(names(await forward.systemPrompt.assemble())).toEqual(['alpha', 'zulu'])
expect(names(await backward.systemPrompt.assemble())).toEqual(['alpha', 'zulu'])
})
it('applies a configured toolOrder: listed positions, rest at the rest entry lexicographically', async () => {
const ctx = await mount({ toolOrder: ['todo_write', TOOL_ORDER_REST, 'bash'] })
ctx.systemPrompt.tools(() => [tool('bash'), tool('echo_b'), tool('todo_write'), tool('echo_a')])
expect(names(await ctx.systemPrompt.assemble())).toEqual(['todo_write', 'echo_a', 'echo_b', 'bash'])
})
it('rejects the assembly when toolOrder names a tool that is not registered (misconfiguration blocks work)', async () => {
const ctx = await mount({ toolOrder: ['todo_write', 'ghost', TOOL_ORDER_REST, 'wraith'] })
ctx.systemPrompt.tools(() => [tool('bash'), tool('todo_write')])
await expect(ctx.systemPrompt.assemble()).rejects.toThrow(
'toolOrder lists unregistered tools "ghost", "wraith"; registered tools: bash, todo_write')
})
it('names the single unregistered tool when no tools are registered at all', async () => {
const ctx = await mount({ toolOrder: ['ghost', TOOL_ORDER_REST] })
await expect(ctx.systemPrompt.assemble()).rejects.toThrow(
'toolOrder lists unregistered tool "ghost"; registered tools: (none)')
})
it.each([
['without an explicit toolOrder', undefined],
['with only the rest entry configured', [TOOL_ORDER_REST]],
])('rejects a provider tool named like the reserved rest entry %s', async (_case, toolOrder) => {
const ctx = await mount(toolOrder === undefined ? {} : { toolOrder })
ctx.systemPrompt.tools(() => [tool(TOOL_ORDER_REST)])
await expect(ctx.systemPrompt.assemble()).rejects.toThrow(
`tool provider returned reserved tool name "${TOOL_ORDER_REST}"`)
})
it('keeps collection order between tools that share a name (stable sort)', async () => {
const ctx = await mount()
ctx.systemPrompt.tools(() => [tool('dup', 'first'), tool('anchor'), tool('dup', 'second')])
const assembly = await ctx.systemPrompt.assemble()
expect(assembly.tools.map(t => t.description)).toEqual(['anchor', 'first', 'second'])
})
it('canonicalizes BEFORE the assemble waterfall: listeners see the ordered list and own their own edits', async () => {
const ctx = await mount()
ctx.systemPrompt.tools(() => [tool('zulu'), tool('alpha')])
let seen: string[] | undefined
ctx.on('system-prompt/assemble', function (assembly, _context, next) {
seen = assembly.tools.map(t => t.name)
// A listener-appended tool is NOT re-sorted — same contract as sections:
// canonicalization applies to what the registry contributed, and a
// listener owns the determinism of what it emits.
assembly.tools.push(tool('aardvark'))
return next()
})
const assembly = await ctx.systemPrompt.assemble()
expect(seen).toEqual(['alpha', 'zulu'])
expect(names(assembly)).toEqual(['alpha', 'zulu', 'aardvark'])
})
it.each([
['an empty list', []],
['a list without the rest entry', ['bash', 'todo_write']],
])('rejects %s at load (the rest entry is required)', async (_case, toolOrder) => {
await expect(new Context().plugin(SystemPrompt, { toolOrder })).rejects.toThrow(`must contain the "${TOOL_ORDER_REST}" rest entry`)
})
it.each([
['a duplicate tool name', ['bash', 'bash', TOOL_ORDER_REST]],
['a duplicate rest entry', [TOOL_ORDER_REST, 'bash', TOOL_ORDER_REST]],
])('rejects %s at load', async (_case, toolOrder) => {
await expect(new Context().plugin(SystemPrompt, { toolOrder })).rejects.toThrow('more than once')
})
it('throws from direct construction too', () => {
expect(() => new SystemPrompt(new Context(), { toolOrder: ['bash'] })).toThrow('rest entry')
})
})

View File

@@ -71,6 +71,12 @@ A `defineTool` tool also **validates the model-generated arguments against its `
See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, and `schemaSpecToJsonSchema` in the public API for details.
### Structured-output schema subset
A separate vocabulary for callers that DEMAND a machine-readable value from an agent — the subagent seam's `SubagentStartRequest.outputSchema` (and, by extension, a workflow's `agent({ schema })`). Unlike `SchemaSpec` (the author-facing DSL for tool parameters), a `StructuredOutputSchema` is an object-rooted **raw JSON Schema subset** as data: it travels verbatim to the model as a forced tool's `parameters`, and the produced value is validated against it.
The subset is deliberately narrow and REJECTS LOUD outside it — accepting a keyword the validator doesn't enforce would validate less than the schema promises (accepted-then-ignored). Supported: single-string `type` (`object`/`array`/`string`/`number`/`integer`/`boolean`/`null`; type arrays rejected), `properties`/`required`/`additionalProperties` (boolean; every `required` key must be declared), `items`, scalar-only `enum`/`const`; annotations (`description`/`title`/`default`/`examples`) are ignored but must still be JSON data. `assertSupportedOutputSchema(schema)` throws `OutputSchemaError` (`code: 'UNSUPPORTED_SCHEMA'`, listing every violation) for anything else; `validateStructuredValue(schema, value)` returns path-qualified violations (empty = valid, total — never throws).
### Tool-owned UI presentation
A tool owns how ITS calls render in a UI (an editor's tool-call card, a CLI log line) — a UI plugin must NOT special-case tool names. A `ToolDefinition` may declare two optional, pure, display-only methods that return a **`card`-tagged render intent** (a discriminated union — a tool declares its card kind once and a UI bridge switches on `card`):

View File

@@ -28,6 +28,16 @@ export {
type JsonSchemaObject,
} from './schema.ts'
export {
assertSupportedOutputSchema,
validateStructuredValue,
OutputSchemaError,
type StructuredOutputSchema,
type StructuredSchemaNode,
type StructuredSchemaType,
type StructuredScalar,
} from './json-schema.ts'
// The render-intent vocabulary a tool declares via `presentCall`/`presentResult`
// lives in its own UI-facing module; re-export it so `@deepseek-ai/dsh-tools`
// stays the single public surface for consumers (producers + the ACP bridge).

View File

@@ -0,0 +1,345 @@
/**
* Structured-output JSON Schema subset: the vocabulary a caller uses to demand
* a machine-readable result from a subagent (`SubagentStartRequest.outputSchema`)
* or a workflow `agent()` call.
*
* This is deliberately NOT full JSON Schema. The schema travels verbatim to the
* model as a forced tool's `parameters`, and the value the model produces is
* validated here — so every accepted keyword must be one this module actually
* enforces. Accepting a keyword we don't enforce would validate less than the
* schema promises (accepted-then-ignored), so anything outside the subset is
* REJECTED LOUD by {@link assertSupportedOutputSchema} instead. The subset:
*
* - `type` — a single string (`object`/`array`/`string`/`number`/`integer`/
* `boolean`/`null`); type ARRAYS (`["string","null"]`) are rejected.
* - `properties`/`required`/`additionalProperties` (boolean) on objects; every
* `required` key must be declared in `properties`. `additionalProperties`
* absent keeps standard JSON Schema semantics (extra keys allowed).
* - `items` on arrays (absent ⇒ any JSON items).
* - `enum` (non-empty, scalars only) and `const` (scalar) on scalar types.
* - Annotations `description`/`title`/`default`/`examples` are allowed and
* ignored (they constrain nothing), except that they must still be JSON data
* — the schema is serialized onto the wire, so a non-JSON annotation would be
* silently mangled.
*
* Values checked by {@link validateStructuredValue} are expected to be plain
* host-realm JSON data (model tool-call arguments are parsed wire JSON; a
* caller holding foreign-realm data materializes it first).
*
* @module dsh-tools/json-schema
*/
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
/** The scalar values `enum`/`const` may carry (finite numbers only). */
export type StructuredScalar = string | number | boolean | null
/** The `type` keywords the subset accepts. */
export type StructuredSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null'
/**
* One node of the structured-output schema subset. Recursive via `properties`
* and `items`; see the module doc for the exact keyword semantics.
*/
export interface StructuredSchemaNode {
type: StructuredSchemaType
/** Nested property schemas (`type: 'object'` only). */
properties?: Record<string, StructuredSchemaNode>
/** Required property names; each must appear in `properties`. */
required?: string[]
/** `false` rejects undeclared keys; absent/`true` allows them (JSON Schema default). */
additionalProperties?: boolean
/** Item schema (`type: 'array'` only); absent ⇒ any JSON items. */
items?: StructuredSchemaNode
/** Allowed values (scalar types only). */
enum?: StructuredScalar[]
/** The single allowed value (scalar types only). */
const?: StructuredScalar
/** Annotation, ignored for validation. */
description?: string
/** Annotation, ignored for validation. */
title?: string
/** Annotation, ignored for validation (must still be JSON data). */
default?: unknown
/** Annotation, ignored for validation (must still be JSON data). */
examples?: unknown
}
/** A structured-output schema: an OBJECT-rooted {@link StructuredSchemaNode}. */
export type StructuredOutputSchema = StructuredSchemaNode & { type: 'object' }
/**
* Thrown by {@link assertSupportedOutputSchema} when a schema falls outside the
* supported subset. Extends {@link HarnessError} (`code: 'UNSUPPORTED_SCHEMA'`)
* so seam code and tool results can route on it; `violations` lists every
* offending path, not just the first.
*/
export class OutputSchemaError extends HarnessError {
/** The individual violation messages, in walk order. */
readonly violations: string[]
constructor(violations: string[]) {
super(`unsupported output schema: ${violations.join('; ')}`, 'UNSUPPORTED_SCHEMA')
this.name = 'OutputSchemaError'
this.violations = violations
}
}
/** The keywords the subset accepts, checked (`constraint`) or ignored (`annotation`). */
const CONSTRAINT_KEYWORDS = new Set(['type', 'properties', 'required', 'additionalProperties', 'items', 'enum', 'const'])
const ANNOTATION_KEYWORDS = new Set(['description', 'title', 'default', 'examples'])
const SCHEMA_TYPES: readonly StructuredSchemaType[] = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null']
/**
* Whether a value is a PLAIN JSON object — non-null, non-array, and with a
* prototype chain of at most one link (`null`-proto, or any realm's
* `Object.prototype`, whose own prototype is `null`). Realm-agnostic on
* purpose: a schema materialized in another realm carries THAT realm's
* `Object.prototype`, which an identity check would wrongly reject. Exotic
* hosts (`Date`, `Map`, class instances) have longer chains and are rejected —
* they would serialize lossily (`Date` → string, `Map` → `{}`) instead of
* failing loud.
*/
function isObjectLike(value: unknown): value is Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
const proto: unknown = Object.getPrototypeOf(value)
return proto === null || Object.getPrototypeOf(proto) === null
}
/** Whether a value is a supported scalar (`enum`/`const` member): string, finite number, boolean, or null. */
function isStructuredScalar(value: unknown): value is StructuredScalar {
return value === null || typeof value === 'string' || typeof value === 'boolean'
|| (typeof value === 'number' && Number.isFinite(value))
}
/**
* Whether a value is JSON data (annotation payloads only): scalars, arrays, and
* object-likes of such values. Realm-agnostic on purpose (no prototype check) —
* the schema may have been materialized from another realm; structural JSON-ness
* is what the wire needs. Cycles are rejected via `seen`.
*/
function isJsonData(value: unknown, seen: Set<object>): boolean {
if (isStructuredScalar(value)) return true
// The scalar check above already returned for null, so `object` here is a real object.
if (typeof value !== 'object') return false
if (seen.has(value)) return false
seen.add(value)
try {
if (Array.isArray(value)) return value.every(entry => isJsonData(entry, seen))
// A non-plain object (Date, Map, class instance) is NOT JSON data even when
// it has no enumerable values — it would serialize lossily, not loudly.
if (!isObjectLike(value)) return false
return Object.values(value).every(entry => isJsonData(entry, seen))
} finally {
seen.delete(value)
}
}
/** Collect subset violations for one schema node (recursive walk). */
function checkSchemaNode(node: unknown, path: string, violations: string[], seen: Set<object>): void {
if (!isObjectLike(node)) {
violations.push(`${path} must be a schema object`)
return
}
if (seen.has(node)) {
violations.push(`${path} is circular`)
return
}
seen.add(node)
for (const key of Object.keys(node)) {
if (CONSTRAINT_KEYWORDS.has(key)) continue
if (ANNOTATION_KEYWORDS.has(key)) {
if (!isJsonData(node[key], new Set())) violations.push(`${path}.${key} annotation must be JSON data`)
continue
}
violations.push(`${path}.${key} is not a supported keyword (subset: type/properties/required/additionalProperties/items/enum/const + annotations)`)
}
if (typeof node.description !== 'undefined' && typeof node.description !== 'string') {
violations.push(`${path}.description must be a string`)
}
if (typeof node.title !== 'undefined' && typeof node.title !== 'string') {
violations.push(`${path}.title must be a string`)
}
const type = node.type
if (typeof type !== 'string' || !(SCHEMA_TYPES as readonly unknown[]).includes(type)) {
violations.push(Array.isArray(type)
? `${path}.type must be a single type string (type arrays are not supported)`
: `${path}.type must be one of ${SCHEMA_TYPES.join('/')}`)
seen.delete(node)
return
}
const schemaType = type as StructuredSchemaType
// Keywords that only make sense on one type are rejected elsewhere — an
// `items` on an object (or `properties` on a string) is a schema-author bug
// the subset surfaces rather than ignores.
const allowedFor: Record<string, StructuredSchemaType[]> = {
properties: ['object'],
required: ['object'],
additionalProperties: ['object'],
items: ['array'],
enum: ['string', 'number', 'integer', 'boolean', 'null'],
const: ['string', 'number', 'integer', 'boolean', 'null'],
}
for (const [key, types] of Object.entries(allowedFor)) {
if (key in node && !types.includes(schemaType)) {
violations.push(`${path}.${key} is not supported on type "${schemaType}"`)
}
}
switch (schemaType) {
case 'object': {
const properties = node.properties
if (properties !== undefined) {
if (!isObjectLike(properties)) {
violations.push(`${path}.properties must be an object of schemas`)
} else {
for (const [key, child] of Object.entries(properties)) {
checkSchemaNode(child, `${path}.properties.${key}`, violations, seen)
}
}
}
const required = node.required
if (required !== undefined) {
if (!Array.isArray(required) || required.some(entry => typeof entry !== 'string')) {
violations.push(`${path}.required must be an array of strings`)
} else {
const declared = isObjectLike(properties) ? properties : {}
// The guard above proved every entry is a string.
for (const key of required as string[]) {
// Own-property check: `in` would let inherited names (`toString`)
// satisfy the declared-in-properties contract via the prototype.
if (!Object.hasOwn(declared, key)) violations.push(`${path}.required names "${key}" which is not in properties`)
}
}
}
if (node.additionalProperties !== undefined && typeof node.additionalProperties !== 'boolean') {
violations.push(`${path}.additionalProperties must be a boolean`)
}
break
}
case 'array': {
if (node.items !== undefined) checkSchemaNode(node.items, `${path}.items`, violations, seen)
break
}
case 'string':
case 'number':
case 'integer':
case 'boolean':
case 'null': {
const allowed = node.enum
if (allowed !== undefined) {
if (!Array.isArray(allowed) || allowed.length === 0 || !allowed.every(entry => isStructuredScalar(entry))) {
violations.push(`${path}.enum must be a non-empty array of scalars`)
}
}
if ('const' in node && !isStructuredScalar(node.const)) {
violations.push(`${path}.const must be a scalar`)
}
break
}
/* v8 ignore start -- defensive: schemaType was membership-checked against SCHEMA_TYPES above, so no runtime value reaches here */
default:
assertNever(schemaType, 'assertSupportedOutputSchema')
/* v8 ignore stop */
}
seen.delete(node)
}
/**
* Assert `schema` is a supported {@link StructuredOutputSchema} — object-rooted
* and entirely within the enforced subset. Throws {@link OutputSchemaError}
* (`UNSUPPORTED_SCHEMA`) listing EVERY violation; returns (and narrows) on
* success. Call this at the seam boundary, before any child is created.
* @param schema - the caller-supplied schema (unknown until asserted).
* @returns nothing — the assertion signature narrows `schema` to
* {@link StructuredOutputSchema} in the caller's scope on normal return.
*/
export function assertSupportedOutputSchema(schema: unknown): asserts schema is StructuredOutputSchema {
const violations: string[] = []
checkSchemaNode(schema, 'schema', violations, new Set())
if (violations.length === 0 && (schema as StructuredSchemaNode).type !== 'object') {
violations.push('schema.type must be "object" (structured output is object-rooted)')
}
if (violations.length > 0) throw new OutputSchemaError(violations)
}
/** Collect violations for one value against an (already asserted) schema node. */
function checkValue(node: StructuredSchemaNode, value: unknown, path: string): string[] {
switch (node.type) {
case 'object': {
if (!isObjectLike(value)) return [`"${path}" must be an object`]
const violations: string[] = []
const properties = node.properties ?? {}
// Own-property discipline throughout: JSON carries own enumerable
// properties only, so an inherited `toString` must not satisfy
// `required`, dodge `additionalProperties: false`, or be validated as if
// the value carried it.
for (const key of node.required ?? []) {
if (!Object.hasOwn(value, key) || value[key] === undefined) violations.push(`missing required property "${path}.${key}"`)
}
for (const [key, child] of Object.entries(properties)) {
if (!Object.hasOwn(value, key) || value[key] === undefined) continue
violations.push(...checkValue(child, value[key], `${path}.${key}`))
}
if (node.additionalProperties === false) {
for (const key of Object.keys(value)) {
if (!Object.hasOwn(properties, key)) violations.push(`"${path}.${key}" is not a declared property (additionalProperties: false)`)
}
}
return violations
}
case 'array': {
if (!Array.isArray(value)) return [`"${path}" must be an array`]
if (!node.items) return []
const items = node.items
return value.flatMap((entry, index) => checkValue(items, entry, `${path}[${index}]`))
}
case 'string': {
if (typeof value !== 'string') return [`"${path}" must be a string`]
break
}
case 'number': {
if (typeof value !== 'number' || !Number.isFinite(value)) return [`"${path}" must be a finite number`]
break
}
case 'integer': {
if (typeof value !== 'number' || !Number.isInteger(value)) return [`"${path}" must be an integer`]
break
}
case 'boolean': {
if (typeof value !== 'boolean') return [`"${path}" must be a boolean`]
break
}
case 'null': {
if (value !== null) return [`"${path}" must be null`]
break
}
default:
return assertNever(node.type, 'validateStructuredValue')
}
// Scalar constraint checks, shared by every scalar branch above.
if (node.enum && !node.enum.includes(value)) {
return [`"${path}" must be one of ${JSON.stringify(node.enum)}`]
}
if ('const' in node && value !== node.const) {
return [`"${path}" must be ${JSON.stringify(node.const)}`]
}
return []
}
/**
* Validate a value against an (already {@link assertSupportedOutputSchema}-
* asserted) schema. Returns human-readable, path-qualified violation messages
* — empty means valid. Total: never throws, however malformed the value.
* @param schema - the asserted schema to check against.
* @param value - the candidate value (e.g. parsed tool-call arguments).
* @returns every violation found, in walk order (empty = valid).
*/
export function validateStructuredValue(schema: StructuredOutputSchema, value: unknown): string[] {
return checkValue(schema, value, 'value')
}

View File

@@ -0,0 +1,304 @@
import { describe, expect, it } from 'vitest'
import {
assertSupportedOutputSchema,
OutputSchemaError,
validateStructuredValue,
type StructuredOutputSchema,
} from '../src/json-schema.ts'
/** Assert-and-narrow helper: the asserted schema, typed. */
function asserted(schema: unknown): StructuredOutputSchema {
assertSupportedOutputSchema(schema)
return schema
}
/** The violations OutputSchemaError carries for a bad schema (throws if it passes). */
function violationsOf(schema: unknown): string[] {
try {
assertSupportedOutputSchema(schema)
} catch (error: unknown) {
if (error instanceof OutputSchemaError) return error.violations
throw error
}
throw new Error('expected the schema to be rejected')
}
describe('assertSupportedOutputSchema', () => {
it('accepts a representative subset schema (all supported keywords)', () => {
const schema = asserted({
type: 'object',
description: 'a finding',
title: 'Finding',
properties: {
file: { type: 'string', description: 'path' },
line: { type: 'integer' },
severity: { type: 'string', enum: ['low', 'high'] },
kind: { type: 'string', const: 'bug' },
score: { type: 'number' },
confirmed: { type: 'boolean' },
parent: { type: 'null' },
tags: { type: 'array', items: { type: 'string' } },
nested: {
type: 'object',
properties: { x: { type: 'number', default: 3, examples: [1, 2] } },
additionalProperties: false,
},
anything: { type: 'array' },
},
required: ['file', 'line'],
additionalProperties: true,
})
expect(schema.type).toBe('object')
})
it('rejects a non-object root (scalar/array-rooted schemas)', () => {
expect(violationsOf({ type: 'string' })).toEqual(['schema.type must be "object" (structured output is object-rooted)'])
expect(violationsOf({ type: 'array', items: { type: 'string' } }))
.toContain('schema.type must be "object" (structured output is object-rooted)')
})
it('rejects non-object schema nodes and missing/unknown type', () => {
expect(violationsOf('nope')).toEqual(['schema must be a schema object'])
expect(violationsOf(null)).toEqual(['schema must be a schema object'])
expect(violationsOf([])).toEqual(['schema must be a schema object'])
expect(violationsOf({})).toEqual(['schema.type must be one of object/array/string/number/integer/boolean/null'])
expect(violationsOf({ type: 'tuple' })[0]).toMatch(/type must be one of/)
expect(violationsOf({ type: 'object', properties: { a: 'str' } })).toEqual(['schema.properties.a must be a schema object'])
})
it('rejects type ARRAYS with a dedicated message', () => {
expect(violationsOf({ type: ['string', 'null'] }))
.toEqual(['schema.type must be a single type string (type arrays are not supported)'])
})
it('rejects unsupported constraint keywords loudly (never accepted-then-ignored)', () => {
for (const keyword of ['oneOf', 'anyOf', 'allOf', 'not', 'pattern', 'minimum', 'maxLength', '$ref']) {
const bad = violationsOf({ type: 'object', [keyword]: [] })
expect(bad.some(v => v.includes(`schema.${keyword} is not a supported keyword`))).toBe(true)
}
})
it('reports EVERY violation, not just the first', () => {
const bad = violationsOf({
type: 'object',
pattern: 'x',
properties: { a: { type: 'weird' }, b: { type: 'string', minimum: 1 } },
})
expect(bad.length).toBe(3)
})
it('rejects keywords on the wrong type (items on object, properties on string, enum on object)', () => {
expect(violationsOf({ type: 'object', items: { type: 'string' } }))
.toEqual(['schema.items is not supported on type "object"'])
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', properties: {} } } }))
.toEqual(['schema.properties.a.properties is not supported on type "string"'])
expect(violationsOf({ type: 'object', enum: [1] }))
.toEqual(['schema.enum is not supported on type "object"'])
expect(violationsOf({ type: 'object', properties: { a: { type: 'array', const: 1 } } }))
.toEqual(['schema.properties.a.const is not supported on type "array"'])
})
it('validates required: must be string[] naming declared properties', () => {
expect(violationsOf({ type: 'object', required: 'file' }))
.toEqual(['schema.required must be an array of strings'])
expect(violationsOf({ type: 'object', required: [1] }))
.toEqual(['schema.required must be an array of strings'])
expect(violationsOf({ type: 'object', properties: { a: { type: 'string' } }, required: ['b'] }))
.toEqual(['schema.required names "b" which is not in properties'])
expect(violationsOf({ type: 'object', required: ['a'] }))
.toEqual(['schema.required names "a" which is not in properties'])
})
it('validates additionalProperties must be boolean and enum/const must be scalars', () => {
expect(violationsOf({ type: 'object', additionalProperties: {} }))
.toEqual(['schema.additionalProperties must be a boolean'])
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: [] } } }))
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: [{}] } } }))
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: 'x' } } }))
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
expect(violationsOf({ type: 'object', properties: { a: { type: 'number', enum: [Number.NaN] } } }))
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', const: {} } } }))
.toEqual(['schema.properties.a.const must be a scalar'])
})
it('rejects non-string description/title and non-JSON annotation payloads', () => {
expect(violationsOf({ type: 'object', description: 7 }))
.toEqual(['schema.description must be a string'])
expect(violationsOf({ type: 'object', title: 7 }))
.toEqual(['schema.title must be a string'])
expect(violationsOf({ type: 'object', default: () => 1 }))
.toEqual(['schema.default annotation must be JSON data'])
expect(violationsOf({ type: 'object', examples: [undefined] }))
.toEqual(['schema.examples annotation must be JSON data'])
expect(violationsOf({ type: 'object', examples: [Number.POSITIVE_INFINITY] }))
.toEqual(['schema.examples annotation must be JSON data'])
// A cyclic annotation payload is caught by the JSON-data walk.
const cyclicAnnotation: Record<string, unknown> = {}
cyclicAnnotation.self = cyclicAnnotation
expect(violationsOf({ type: 'object', default: cyclicAnnotation }))
.toEqual(['schema.default annotation must be JSON data'])
// Object/array annotations that ARE JSON data pass.
asserted({ type: 'object', default: { a: [1, 'x', null, true] } })
})
it('rejects a circular schema instead of recursing forever', () => {
const node: Record<string, unknown> = { type: 'object' }
node.properties = { self: node }
expect(violationsOf(node)).toEqual(['schema.properties.self is circular'])
})
it('accepts the same subschema object reused in two SIBLING positions (a DAG, not a cycle)', () => {
const leaf = { type: 'string' }
asserted({ type: 'object', properties: { a: leaf, b: leaf } })
})
it('required cannot be satisfied by INHERITED names — `toString` is not a declared property', () => {
// `'toString' in {}` is true via Object.prototype; the declared-property
// contract must be an own-property check.
expect(violationsOf({ type: 'object', properties: {}, required: ['toString'] }))
.toEqual(['schema.required names "toString" which is not in properties'])
})
it('rejects exotic host objects where the subset expects plain JSON structure', () => {
// A Map as `properties` has no own enumerable entries: structurally it
// would read as "no properties" and serialize to {} — lossy, not loud.
expect(violationsOf({ type: 'object', properties: new Map() }))
.toEqual(['schema.properties must be an object of schemas'])
// A Date node is not a schema object even though Object.values(date) is [].
expect(violationsOf({ type: 'object', properties: { at: new Date(0) } }))
.toEqual(['schema.properties.at must be a schema object'])
})
it('rejects exotic annotation payloads that would serialize lossily', () => {
expect(violationsOf({ type: 'object', default: new Date(0) }))
.toEqual(['schema.default annotation must be JSON data'])
expect(violationsOf({ type: 'object', examples: [new Map()] }))
.toEqual(['schema.examples annotation must be JSON data'])
})
})
describe('validateStructuredValue', () => {
const schema = asserted({
type: 'object',
properties: {
file: { type: 'string' },
line: { type: 'integer' },
score: { type: 'number' },
confirmed: { type: 'boolean' },
parent: { type: 'null' },
severity: { type: 'string', enum: ['low', 'high'] },
kind: { type: 'string', const: 'bug' },
tags: { type: 'array', items: { type: 'string' } },
free: { type: 'array' },
nested: { type: 'object', properties: { x: { type: 'number' } }, required: ['x'], additionalProperties: false },
},
required: ['file'],
})
it('accepts a fully valid value (empty violations)', () => {
expect(validateStructuredValue(schema, {
file: 'a.ts', line: 3, score: 0.5, confirmed: true, parent: null,
severity: 'high', kind: 'bug', tags: ['x'], free: [1, { any: true }], nested: { x: 1 },
})).toEqual([])
})
it('reports missing required and wrong root type', () => {
expect(validateStructuredValue(schema, {})).toEqual(['missing required property "value.file"'])
expect(validateStructuredValue(schema, 'nope')).toEqual(['"value" must be an object'])
expect(validateStructuredValue(schema, [])).toEqual(['"value" must be an object'])
})
it('type-checks every scalar branch with path-qualified messages', () => {
expect(validateStructuredValue(schema, { file: 1 })).toEqual(['"value.file" must be a string'])
expect(validateStructuredValue(schema, { file: 'a', line: 1.5 })).toEqual(['"value.line" must be an integer'])
expect(validateStructuredValue(schema, { file: 'a', line: 'x' })).toEqual(['"value.line" must be an integer'])
expect(validateStructuredValue(schema, { file: 'a', score: 'x' })).toEqual(['"value.score" must be a finite number'])
expect(validateStructuredValue(schema, { file: 'a', score: Number.NaN })).toEqual(['"value.score" must be a finite number'])
expect(validateStructuredValue(schema, { file: 'a', confirmed: 'yes' })).toEqual(['"value.confirmed" must be a boolean'])
expect(validateStructuredValue(schema, { file: 'a', parent: 0 })).toEqual(['"value.parent" must be null'])
})
it('enforces enum membership and const equality', () => {
expect(validateStructuredValue(schema, { file: 'a', severity: 'mid' }))
.toEqual(['"value.severity" must be one of ["low","high"]'])
expect(validateStructuredValue(schema, { file: 'a', kind: 'feature' }))
.toEqual(['"value.kind" must be "bug"'])
})
it('checks arrays per index; an items-less array accepts anything', () => {
expect(validateStructuredValue(schema, { file: 'a', tags: 'x' })).toEqual(['"value.tags" must be an array'])
expect(validateStructuredValue(schema, { file: 'a', tags: ['ok', 2] })).toEqual(['"value.tags[1]" must be a string'])
expect(validateStructuredValue(schema, { file: 'a', free: [{ deep: [1] }, null] })).toEqual([])
})
it('recurses into nested objects: required + additionalProperties: false', () => {
expect(validateStructuredValue(schema, { file: 'a', nested: {} }))
.toEqual(['missing required property "value.nested.x"'])
expect(validateStructuredValue(schema, { file: 'a', nested: { x: 1, y: 2 } }))
.toEqual(['"value.nested.y" is not a declared property (additionalProperties: false)'])
expect(validateStructuredValue(schema, { file: 'a', nested: 3 }))
.toEqual(['"value.nested" must be an object'])
})
it('a required key present-but-undefined counts as missing', () => {
expect(validateStructuredValue(schema, { file: undefined })).toEqual(['missing required property "value.file"'])
})
it('inherited properties satisfy nothing: required, additionalProperties, and recursion are own-property only', () => {
// required: ['toString'] must NOT be satisfied by Object.prototype.toString.
expect(validateStructuredValue(
asserted({ type: 'object', properties: { toString: { type: 'string' } }, required: ['toString'] }),
{},
)).toEqual(['missing required property "value.toString"'])
// additionalProperties: false must flag an OWN `toString` key even though
// `'toString' in properties` is true via the prototype.
expect(validateStructuredValue(
asserted({ type: 'object', additionalProperties: false }),
{ toString: 1 },
)).toEqual(['"value.toString" is not a declared property (additionalProperties: false)'])
// A declared property the value does NOT carry must not be validated
// against the value's INHERITED member (constructor is a function on
// every plain object's prototype, not a carried property).
expect(validateStructuredValue(
asserted({ type: 'object', properties: { constructor: { type: 'string' } } }),
{},
)).toEqual([])
})
it('a non-plain object value is not an object in the JSON sense', () => {
expect(validateStructuredValue(asserted({ type: 'object' }), new Date(0)))
.toEqual(['"value" must be an object'])
})
it('collects multiple violations across branches in one pass', () => {
expect(validateStructuredValue(schema, { line: 'x', severity: 'mid' })).toEqual([
'missing required property "value.file"',
'"value.line" must be an integer',
'"value.severity" must be one of ["low","high"]',
])
})
it('null-typed const/enum work through the scalar path', () => {
const nullish = asserted({ type: 'object', properties: { a: { type: 'null', const: null } } })
expect(validateStructuredValue(nullish, { a: null })).toEqual([])
})
it('rejects a non-object properties value in the schema walk', () => {
expect(violationsOf({ type: 'object', properties: [] }))
.toEqual(['schema.properties must be an object of schemas'])
})
it('an object schema without properties/required only type-checks its value', () => {
const bare = asserted({ type: 'object' })
expect(validateStructuredValue(bare, { any: ['thing'] })).toEqual([])
expect(validateStructuredValue(bare, 7)).toEqual(['"value" must be an object'])
})
it('validateStructuredValue throws on a type the assert would never let through (assertNever backstop)', () => {
const forged = { type: 'tuple' } as unknown as StructuredOutputSchema
expect(() => validateStructuredValue(forged, 1)).toThrow(/tuple/)
})
})

View File

@@ -8,7 +8,7 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i
Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)``data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../docs/rfc/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`) lives in a `sessions` row. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row), so no separate column is needed.
The repo targets Node ≥ 24 (the root `engines` field), which includes the stable `node:sqlite` module. The database opens with `foreign_keys = ON` (so `ON DELETE CASCADE` drops a session's events with its row) and the configured `journal_mode` (default `wal`; pick a rollback-journal mode like `delete` on filesystems where WAL's shared-memory files do not work, e.g. network mounts). The table-layout version is stored in `PRAGMA user_version` and checked on open: a fresh database is stamped with the current `SCHEMA_VERSION`; a database written by any other, incompatible build (a non-current `user_version`, older or newer) is rejected rather than opened against an unknown layout — there is no migration (unreleased software).
The repo's `engines.node` is `^22.19.0 || >=24.0.0` (Node 22.19+ or 24+), matching the LTS floor required by the installed Pi adapter dependency; `node:sqlite` itself ships without the `--experimental-sqlite` flag from Node 22.13 (LTS) and 23.4 / 24 (Current) on. The range deliberately excludes Node 23 because that line is non-LTS/EOL and still has flagged runtime features before 23.6. The database opens with `foreign_keys = ON` (so `ON DELETE CASCADE` drops a session's events with its row) and the configured `journal_mode` (default `wal`; pick a rollback-journal mode like `delete` on filesystems where WAL's shared-memory files do not work, e.g. network mounts). The table-layout version is stored in `PRAGMA user_version` and checked on open: a fresh database is stamped with the current `SCHEMA_VERSION`; a database written by any other, incompatible build (a non-current `user_version`, older or newer) is rejected rather than opened against an unknown layout — there is no migration (unreleased software).
## Contract semantics over rows

View File

@@ -12,7 +12,7 @@ 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

View File

@@ -28,6 +28,10 @@ import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } fro
import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess'
export const name = 'subagent-fork'
// `tools` is deliberately NOT injected — same rationale as subagent-spawn: the
// per-run 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. */
@@ -59,11 +63,12 @@ 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

View File

@@ -9,9 +9,10 @@ import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import SubagentService from '@deepseek-ai/dsh-subagent'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import * as fork from '../src/index.ts'
import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess'
import { completedTurnPrefix } from '../src/index.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
@@ -141,6 +142,26 @@ describe('dsh-subagent-fork', () => {
await run.dispose()
})
it('captures structured output through the shipped plugin (seeded child, driver runtime)', async () => {
const { ctx, parent } = await setup([
textResponse('parent turn'),
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 9 }),
])
parent.send([{ type: 'text', text: 'warm up' }])
await parent.whenIdle()
const run = ctx.subagents.start('fork', {
prompt: [{ type: 'text', text: 'report structured' }],
parent,
outputSchema: { type: 'object', properties: { answer: { type: 'number' } }, required: ['answer'] },
})
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(result.structured).toEqual({ answer: 9 })
// Run-scoped runtime: nothing stays registered after the settle.
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
await run.dispose()
})
it('does NOT return the seeded parent output when the child produces no message of its own', async () => {
// Regression: readResult must scope to the child's OWN events (after the
// seed). The parent completes a turn with a distinctive assistant message,
@@ -161,9 +182,9 @@ 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 () => {

View File

@@ -8,10 +8,10 @@ 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)) and then snapshotted with `structuredClone` before any child exists — assertion first so a hostile value fails as `OutputSchemaError` (never a raw clone error), the snapshot so a post-`start()` caller mutation cannot drift the enforced schema;
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); 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).
`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`.
@@ -19,6 +19,19 @@ Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (
`{ providerName: string; seed?: SessionEvent[] }` — the per-backend inputs: the provider name (for error context) and the optional child-session seed.
### Structured output (package-internal runtime)
The mechanism behind `outputSchema` for in-process children — acquired per structured RUN inside `startInProcessRun` (nothing is registered on a context that never runs a structured child; only the model-facing constants `STRUCTURED_OUTPUT_TOOL`/`STRUCTURED_OUTPUT_INSTRUCTION` are exported). One globally registered `structured_output` capture tool (its registered parameters are a placeholder) plus four listeners:
- a `system-prompt/assemble` waterfall listener registered `prepend: true` that post-processes `await next()`**final-assembly enforcement**: 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 (as the tool's `parameters`) plus the calling instruction as a trailing prompt section (the demand travels with the tool — `AgentOptions` has no per-agent prompt field to carry it). The loop logs the rendered assembly as the step's `request/header`, so the injection is reconstructable log state, never a wire-only mutation. Per-agent shaping lives here because the tool registry and prompt assembly are context-global while schemas differ per concurrent child (FIXME in the module doc: per-agent/per-session scoping would dissolve this); cooperative mutate-then-`next()` would not survive a downstream listener returning a replacement assembly.
- a `tools/post-execute` listener (`prepend: true` = outermost, so `await next()` yields the composed final decision) that COMMITS the capture: the tool body only stages the validated value, and it becomes the run's result only when the final decision accepts the call — a downstream block (a PostToolUse hook) turns the logged result into `isError`, and the run must not report `structured` success for a call the model and session log saw fail.
- a `tools/pre-execute` deny for any call arriving after the agent's capture — terminal means terminal WITHIN the step: a response listing `structured_output` before further tool calls cannot run side effects after the final answer was accepted.
- 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 stages the value for the post-execute commit.
Lifetime is refcounted by live structured runs: each acquires at start and releases at settle, so the registrations exist exactly while at least one structured child is live, a backend hot-reload mid-run cannot unregister the capture tool under a live child, and the last settle disposes everything. `release()` is idempotent per acquisition.
### `depthOf(agent): number`
Delegation depth rides on a merge-extensible `AgentOptions.subagentDepth` field (0 for a top-level agent, parent + 1 for a child), so a nested spawn reads its parent's depth from `parent.options.subagentDepth`. `depthOf` reads it (absent ⇒ 0).

View File

@@ -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": {

View File

@@ -18,7 +18,20 @@ 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,
type StructuredAcquisition,
} from './structured.ts'
// The runtime itself (acquire/attach/release) is package-internal: runs
// acquire it inside startInProcessRun, and no other package drives it. Only
// the model-facing vocabulary is public.
export {
STRUCTURED_OUTPUT_TOOL,
STRUCTURED_OUTPUT_INSTRUCTION,
} from './structured.ts'
declare module '@deepseek-ai/dsh-agent' {
interface AgentOptions {
@@ -109,6 +122,18 @@ export function startInProcessRun(
if (request.maxDepth !== undefined && childDepth > request.maxDepth) {
throw new SubagentDepthError(childDepth, request.maxDepth)
}
// Assert, then snapshot, the schema subset BEFORE any child exists (the
// service has already capability-gated; this rejects a schema outside the
// enforced subset loud). Assertion comes FIRST so a hostile value fails as
// OutputSchemaError, never as structuredClone's raw DataCloneError — the
// asserted subset is plain JSON data, which always clones. The snapshot is
// load-bearing: the caller keeps its reference, so attaching the ORIGINAL
// would let a post-start() mutation drift the enforced schema away from the
// asserted one — the clone (taken synchronously with the assertion, no
// interleaving possible) pins assertion, the model-visible parameters, and
// validateStructuredValue to one isolation-immutable value.
if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema)
const schema = request.outputSchema === undefined ? undefined : structuredClone(request.outputSchema)
const childId = AgentId(randomUUID())
// The child's OWN events begin after the seed (fork seeds the parent's
@@ -120,13 +145,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()),
@@ -141,6 +173,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).
@@ -149,6 +182,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 read at the result mapping would narrow to the initializer.
const isCancelled = (): boolean => cancelled
const requestCancel = (reason: string): void => {
cancelled = true
child.cancel(reason)
@@ -165,9 +202,16 @@ export function startInProcessRun(
if (request.signal?.aborted) return { output: [], stopReason: 'aborted' }
child.send(request.prompt)
await child.whenIdle()
return readResult(child, seedLength, cancelled)
// Deliberately NO re-prompt when a structured child finishes cleanly
// without calling structured_output: readResult maps that to `error` —
// the shortfall goes to the parent instead of buying extra model turns.
return readResult(child, seedLength, isCancelled(), structured ? { captured: structured.captured(child) } : undefined)
} finally {
request.signal?.removeEventListener('abort', onAbort)
if (structured) {
structured.detach(child)
structured.release()
}
}
})()
@@ -195,12 +239,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 }
}

View File

@@ -0,0 +1,312 @@
/**
* 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.)
*
* 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
* 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. A third listener
* closes the within-step window the continuation veto cannot: a
* `tools/pre-execute` deny for any call arriving after the agent's capture, so
* a response that lists `structured_output` before further tool calls cannot
* run side effects after the final answer was accepted. A fourth,
* `tools/post-execute`, is the capture COMMIT: the tool body only stages the
* validated value, and it becomes the run's captured result only when the
* final post-execute decision accepts the call — a blocking hook downstream
* yields `isError` in the log, and the run must not report success for it.
*
* Lifetime is refcounted by structured RUNS: each acquires from start to
* settle, so the registrations exist exactly while at least one structured
* child is live — a plain deployment that never passes `outputSchema` carries
* no always-on global state, and a backend hot-reload mid-run cannot
* unregister the capture tool out from under a live child (the run holds its
* own acquisition). Registrations land on the ROOT context and the refcount
* disposes them when the last run settles; the next structured run
* re-registers them.
*
* @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 { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } 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.'
/** One structured run's state: the schema to enforce and the captured value, once recorded. */
interface RunState {
readonly schema: StructuredOutputSchema
/**
* A validated value awaiting the post-execute verdict on ITS OWN call. Set
* by the capture tool's body, promoted to {@link RunState.captured} only
* when the final `tools/post-execute` decision accepts the call — a
* downstream block turns the logged result into `isError`, and a value
* committed at body time would let the run report success for a call the
* model saw fail.
*/
pending?: { value: unknown }
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 runtime's 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)
// Two-phase commit: the body only STAGES the value; the post-execute
// listener below promotes it once the final decision accepts the call.
state.pending = { 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 }))
// The capture COMMIT: promote the staged value only when the final
// post-execute decision accepts the call. The capture tool's body cannot
// decide — `tools/post-execute` runs after it, and a blocking listener (a
// PostToolUse hook) turns the logged result into `isError` feedback; a value
// committed at body time would make readResult report `structured` success
// for a call whose result the model and session log saw fail. `prepend:
// true` = outermost at registration time, so `await next()` returns the
// COMPOSED downstream decision — the same final verdict the registry maps
// onto the result. (A later-registered outer listener that blocks without
// delegating skips this commit entirely: the staged value is dropped and the
// run errors — failure-safe in the same direction.) The staging slot clears
// on every path, including a rejecting downstream listener.
runtime.disposers.push(root.on('tools/post-execute', async function (
this: unknown, exec: ToolExecution, _result: ToolExecutionResult, next: () => Promise<PostToolDecision>,
): Promise<PostToolDecision> {
const state = exec.agent ? runtime.states.get(exec.agent) : undefined
if (!state || exec.name !== STRUCTURED_OUTPUT_TOOL || state.pending === undefined) return next()
const pending = state.pending
try {
const decision = await next()
if (decision.kind === 'accept') state.captured = pending
return decision
} finally {
delete state.pending
}
}, { prepend: true }))
// Terminal means terminal WITHIN the step, not only at its end: the
// turn-continuation veto above runs after every call in the current model
// response has executed, so a response that puts `structured_output` before
// further tool calls would still perform those side effects after the final
// answer was accepted. Deny every later call for a captured agent at the
// allow/deny gate — dispatch is skipped and the model sees an `isError`
// result naming the contract. Calls that PRECEDE the capture in the same
// response ran before `captured` was set and are untouched; a second
// `structured_output` is denied like any other call. `prepend: true` for the
// same reason as the continuation veto: no earlier-registered allow may
// short-circuit past the terminal contract.
runtime.disposers.push(root.on('tools/pre-execute', function (
this: unknown, exec: ToolExecution, next: () => Promise<PreToolDecision>,
): Promise<PreToolDecision> {
if (exec.agent && runtime.states.get(exec.agent)?.captured) {
return Promise.resolve({
kind: 'deny',
reason: `structured output already recorded: the run is complete, so \`${exec.name}\` is not executed`,
})
}
return next()
}, { prepend: true }))
}

View File

@@ -0,0 +1,606 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId, type ContentBlock, 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 { startInProcessRun } from '../src/index.ts'
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 + an INLINE spawn-shaped provider over the
* shared driver. The concrete backend plugins are deliberately NOT loaded —
* they would devDep-cycle this package (spawn/fork already depend on the
* driver), and the runtime under test is the driver's; plugin-level structured
* coverage lives in the spawn/fork specs. The mock model script drives the
* child's structured_output calls.
*/
async function setup(script: Script) {
const ctx = new Context()
const adapter = new MockAdapter(script)
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(Invariants)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
const disposeProvider = ctx.subagents.registerProvider({
name: 'spawn',
capabilities: { outputSchema: true, depthLimit: true, toolFilter: false },
inheritsParentContext: false,
start: (request: SubagentStartRequest) => startInProcessRun(ctx, request, { providerName: 'spawn' }),
})
ctx.llm.registerAdapter(['mock'], adapter)
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
return { ctx, parent, adapter, disposeProvider }
}
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('denies tool calls that FOLLOW the capture in the same response — terminal means terminal', async () => {
// One model response carrying structured_output FIRST and a side-effecting
// call after it: the continuation veto only fires at step end, so without
// the pre-execute deny the trailing call would still run after the final
// answer was accepted.
const response = [
...toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }).slice(0, -2),
{ type: 'block-start', index: 1, blockType: 'tool-call' },
{ type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('c2'), name: 'side_effect', arguments: '{}' } },
{ type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } },
{ type: 'finish', reason: { kind: 'tool-calls' } },
] as Script[number]
const { ctx, parent } = await setup([response])
let sideEffectRan = false
ctx.tools.register({
name: 'side_effect',
description: 'probe',
parameters: { type: 'object', properties: {} },
execute(): Promise<ContentBlock[]> {
sideEffectRan = true
return Promise.resolve([{ type: 'text', text: 'ran' }])
},
})
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(result.structured).toEqual({ answer: 5 })
// The deny skipped dispatch entirely: the probe body never ran.
expect(sideEffectRan).toBe(false)
await run.dispose()
})
it('leaves tool calls that PRECEDE the capture in the same response untouched', async () => {
const response = [
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('c1'), name: 'side_effect', arguments: '{}' } },
...toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, { answer: 6 }).map(chunk =>
'index' in chunk ? { ...chunk, index: 1 } : chunk),
] as Script[number]
const { ctx, parent } = await setup([response])
let sideEffectRan = false
ctx.tools.register({
name: 'side_effect',
description: 'probe',
parameters: { type: 'object', properties: {} },
execute(): Promise<ContentBlock[]> {
sideEffectRan = true
return Promise.resolve([{ type: 'text', text: 'ran' }])
},
})
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
// The call ran BEFORE captured was set: the deny gate only guards the
// window after the terminal answer landed.
expect(sideEffectRan).toBe(true)
expect(result.structured).toEqual({ answer: 6 })
await run.dispose()
})
it('snapshots the schema at start(): caller mutation after start cannot drift enforcement', async () => {
const mutable: StructuredOutputSchema = {
type: 'object',
properties: { answer: { type: 'number' } },
required: ['answer'],
additionalProperties: false,
}
const pristine = structuredClone(mutable)
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 3 }),
])
const run = ctx.subagents.start('spawn', structuredRequest(parent, { outputSchema: mutable }))
// Mutate the caller's object AFTER start() returned but before the child's
// first request assembles: with a live reference this would reach both the
// model-visible parameters and validateStructuredValue.
;(mutable.properties as Record<string, unknown>).answer = { type: 'string' }
const result = await run.result
expect(result.structured).toEqual({ answer: 3 })
// The child's request carried the PRISTINE schema, not the mutated one.
const childRequest = adapter.requests.at(-1)
const captureTool = (childRequest?.tools ?? []).find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)
expect(captureTool?.parameters).toEqual(pristine)
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('a clean finish without a capture is an immediate error to the parent — deliberately NO re-prompt', async () => {
const { ctx, parent, adapter } = await setup([
textResponse('here is my answer in prose'),
textResponse('MUST NOT BE CONSUMED'),
])
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.stopReason).toBe('error')
expect(result.structured).toBeUndefined()
// Exactly one model request and one user message: no nudge turn exists.
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()
})
it('an errored child keeps its honest error result (no capture expected)', async () => {
// Script exhaustion on the first call → the child turn errors.
const { ctx, parent, adapter } = await setup([])
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 capture-less turn settles aborted, not error', async () => {
const { ctx, parent } = await setup([textResponse('prose, no capture')])
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const child = ctx.agents.get(run.id)!
// Cancel synchronously inside the turn's end recording: the cancel
// contract outranks the schema shortfall, so the result maps to aborted.
ctx.on('session/event', (session, event) => {
if (session === child.session && event.type === 'turn/end') run.cancel('cancelled at turn end')
})
const result = await run.result
expect(result.stopReason).toBe('aborted')
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('a schema carrying non-JSON values fails as OutputSchemaError, never as a raw clone error', async () => {
const { ctx, parent } = await setup([])
// Assertion runs BEFORE the defensive structuredClone: a function-valued
// annotation must surface as the subset violation it is, not escape as
// structuredClone's DataCloneError.
expect(() => ctx.subagents.start('spawn', structuredRequest(parent, {
outputSchema: { type: 'object', default: () => {} } as unknown as StructuredOutputSchema,
}))).toThrow(/unsupported output schema.*annotation must be JSON data/)
})
it('a post-execute BLOCK on the capture call denies the capture: log and result agree on failure', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
textResponse('continues after the blocked capture'),
])
// A PostToolUse-style hook, registered AFTER the runtime (so the runtime's
// prepend commit listener stays outermost and composes this verdict).
ctx.on('tools/post-execute', (exec, _result, next) => {
if (exec.name === STRUCTURED_OUTPUT_TOOL) {
return Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'capture rejected by hook' }] })
}
return next()
})
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
// No capture was committed: the run reports the schema shortfall...
expect(result.structured).toBeUndefined()
expect(result.stopReason).toBe('error')
// ...the logged tool result is the blocked isError with the feedback...
const child = ctx.agents.get(run.id)!
const results = child.session.events.filter(e => e.type === 'tool/result')
expect((results[0]!.data as { isError?: boolean }).isError).toBe(true)
expect(JSON.stringify((results[0]!.data as { content: unknown }).content)).toContain('capture rejected by hook')
// ...and the turn CONTINUED past the blocked call (no captured veto):
// the model got to react to the failure with a second step.
expect(adapter.requests.length).toBe(2)
await run.dispose()
})
it('a post-execute accept-with-replacement still commits the capture', async () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 8 }),
])
ctx.on('tools/post-execute', (exec, _result, next) => {
if (exec.name === STRUCTURED_OUTPUT_TOOL) {
return Promise.resolve({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'recorded (rewritten)' }] })
}
return next()
})
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(result.structured).toEqual({ answer: 8 })
await run.dispose()
})
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 plain agent assembling while the runtime is LIVE gets the placeholder stripped', async () => {
// Run-scoped acquisition means a plain deployment never registers the
// tool at all; the strip branch exists for the CONCURRENT case — a plain
// agent taking a turn while some structured child holds the runtime open.
const { ctx, parent, adapter } = await setup([textResponse('parent answer')])
const hold = acquireStructuredRuntime(ctx)
parent.send([{ type: 'text', text: 'hello' }])
await parent.whenIdle()
// The placeholder IS in the registry during this turn; the assembly the
// loop rendered must not carry it for an agent without a structured run.
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined()
expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL)
hold.release()
})
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 acquisition = acquireStructuredRuntime(ctx)
// Bare assemble WHILE the runtime is live: the no-agent branch must
// strip the registered placeholder (before the acquisition there is
// nothing to strip — run-scoped registration).
const bare = await ctx.systemPrompt.assemble({})
expect(bare.tools.map(tool => tool.name)).not.toContain(STRUCTURED_OUTPUT_TOOL)
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: live structured runs)', () => {
it('the runtime exists exactly while structured runs are live: nothing before, nothing after', async () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 4 }),
])
// No always-on global state: a context that has run no structured child
// carries no capture tool.
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
// The capture succeeded — the registrations existed while the run lived.
expect(result.structured).toEqual({ answer: 4 })
// The run's settle released the last acquisition.
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
await run.dispose()
})
it('concurrent structured runs share one runtime; the last settle disposes it', async () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, { answer: 2 }),
])
const first = ctx.subagents.start('spawn', structuredRequest(parent))
const second = ctx.subagents.start('spawn', structuredRequest(parent))
const [a, b] = await Promise.all([first.result, second.result])
expect([a.structured, b.structured].sort()).toEqual([{ answer: 1 }, { answer: 2 }].sort())
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
await first.dispose()
await second.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()
// That manual acquisition was the ONLY holder - release disposes.
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
})
})
it('a direct structured_output call from an agent WITHOUT a structured run is an isError', async () => {
const { ctx, parent } = await setup([])
// Hold the runtime open (run-scoped: nothing is registered otherwise) so
// the call reaches the capture tool's own fail-loud guard, not UNKNOWN_TOOL.
const hold = acquireStructuredRuntime(ctx)
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(JSON.stringify(result.content)).toContain('only available to subagents')
hold.release()
})
it('a structured_output call with NO calling agent at all is an isError', async () => {
const { ctx } = await setup([])
const hold = acquireStructuredRuntime(ctx)
const result = await ctx.tools.execute({
callId: 'x' as never,
name: STRUCTURED_OUTPUT_TOOL,
arguments: { answer: 1 },
})
expect(result.isError).toBe(true)
hold.release()
})
})

View File

@@ -25,6 +25,12 @@
},
{
"path": "../subagent"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../core/tools"
}
]
}

View File

@@ -10,7 +10,7 @@ The run mechanics live in the shared [`@deepseek-ai/dsh-subagent-inprocess`](../
## 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 [structured runtime](../subagent-inprocess/README.md) (acquired per structured run inside the driver — this backend registers nothing at apply). Tool-scoping is deferred (the service rejects a request needing it before `start` runs).
## Config

View File

@@ -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
@@ -20,6 +25,11 @@ import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } fro
import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess'
export const name = 'subagent-spawn'
// `tools` is deliberately NOT injected: the shared driver's structured runtime
// (acquired per structured RUN, not at apply) gates its own capture-tool
// registration on `tools` availability, 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. */
@@ -34,11 +44,12 @@ export const Config: z<Config> = z.object({
/**
* 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
@@ -46,7 +57,8 @@ class SpawnProvider implements SubagentProvider {
start(request: SubagentStartRequest) {
// Fresh child: no seed. The shared driver mints ids, stamps cwd/lineage/
// depth, drives the one-shot, and maps the result.
// depth, drives the one-shot (including the structured capture when the
// request carries an outputSchema), and maps the result.
return startInProcessRun(this.ctx, request, { providerName: this.name })
}
}

View File

@@ -10,9 +10,9 @@ import { SessionId } from '@deepseek-ai/dsh-session'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import SubagentService from '@deepseek-ai/dsh-subagent'
import { MockAdapter, maxTokensResponse, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import * as spawn from '../src/index.ts'
import { depthOf, SubagentDepthError } from '@deepseek-ai/dsh-subagent-inprocess'
import { depthOf, STRUCTURED_OUTPUT_TOOL, SubagentDepthError } from '@deepseek-ai/dsh-subagent-inprocess'
type Script = ConstructorParameters<typeof MockAdapter>[0]
@@ -241,10 +241,10 @@ 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 () => {
@@ -257,6 +257,54 @@ describe('dsh-subagent-spawn', () => {
expect(ctx.subagents.list()).toEqual([])
})
it('captures structured output through the shipped plugin (driver runtime, plugin wiring)', async () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42 }),
])
const run = ctx.subagents.start('spawn', {
prompt: [{ type: 'text', text: 'produce the answer' }],
parent,
outputSchema: { type: 'object', properties: { answer: { type: 'number' } }, required: ['answer'] },
})
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(result.structured).toEqual({ answer: 42 })
// Run-scoped runtime: the settle released the last acquisition.
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
await run.dispose()
})
it('a backend unload mid-structured-run settles the run and releases the runtime', async () => {
// Rebuild the stack by hand so we hold the backend's fiber.
const ctx = new Context()
const adapter = new MockAdapter(['hang'])
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(Invariants)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
ctx.llm.registerAdapter(['mock'], adapter)
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
const run = ctx.subagents.start('spawn', {
prompt: [{ type: 'text', text: 'q' }],
parent,
outputSchema: { type: 'object', properties: { a: { type: 'number' } } },
})
// Let the child's step start streaming, then unload the backend. The
// backend owns the child agent, so the unload tears the child down and
// the run settles — releasing its own runtime acquisition on the way out.
await new Promise(resolve => setTimeout(resolve, 30))
await fiber.dispose()
const result = await run.result
expect(result.stopReason).toBe('error')
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
await run.dispose()
})
it('has the namespace-plugin export shape (no stray default)', () => {
expect('default' in spawn).toBe(false)
expect(spawn.name).toBe('subagent-spawn')

View File

@@ -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.

View File

@@ -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)
})
})

View File

@@ -41,13 +41,13 @@ describe('dsh-subagent-mock', () => {
it('surfaces a structured result when the request carries an outputSchema', async () => {
const ctx = await mount({ reply: 'r', structured: { answer: 42 } })
const run = ctx.subagents.start('mock', baseRequest({ outputSchema: { answer: { type: 'number' } } }))
const run = ctx.subagents.start('mock', baseRequest({ outputSchema: { type: 'object', properties: { answer: { type: 'number' } } } }))
await expect(run.result).resolves.toMatchObject({ structured: { answer: 42 } })
})
it('defaults structured output to { reply } when outputSchema is requested but no structured value is configured', async () => {
const ctx = await mount({ reply: 'fallback reply' })
const run = ctx.subagents.start('mock', baseRequest({ outputSchema: { answer: { type: 'number' } } }))
const run = ctx.subagents.start('mock', baseRequest({ outputSchema: { type: 'object', properties: { answer: { type: 'number' } } } }))
await expect(run.result).resolves.toMatchObject({ structured: { reply: 'fallback reply' } })
})

View File

@@ -24,6 +24,7 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron
|---|---|---|
| `model` | (required) | the per-session agent template the bridge creates agents from |
| `persona` | — | the deployment persona template (may reference `{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` |
| `toolOrder` | — | explicit model-facing tool order (a name list with one `'<unlisted-tools>'` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` |
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay) and a bash executor (`bash-local`).

View File

@@ -45,6 +45,7 @@
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-acp": "workspace:^",
"@deepseek-ai/dsh-agent-core": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"cordis": "^4.0.0-rc.6",
"schemastery": "^3.17.0"

View File

@@ -42,7 +42,8 @@ export const name = 'acp-agent'
* App config: the swappable per-deployment values. `model` configures the
* agent template the ACP bridge creates each session's agent from (NOT a
* pre-created agent — ACP creates agents at `session/new`); `persona` is the
* deployment persona (forwarded to the system-prompt plugin);
* deployment persona (forwarded to the system-prompt plugin); `toolOrder` is
* the explicit model-facing tool order (forwarded to the system-prompt plugin);
* `persistenceRoot` is the JSONL backend's directory.
*/
export interface Config {
@@ -50,6 +51,8 @@ export interface Config {
model: string
/** Deployment persona (the system-prompt plugin's `persona` config). */
persona?: string
/** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */
toolOrder?: string[]
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
persistenceRoot?: string
}
@@ -57,6 +60,10 @@ export interface Config {
export const Config: z<Config> = z.object({
model: z.string().required(),
persona: z.string(),
// The array default is forced to undefined: ABSENT means "lexicographic
// order" (the owning dsh-system-prompt schema does the same), while
// schemastery's native [] default would read as an invalid configured list.
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
persistenceRoot: z.string().default('./.sessions'),
})
@@ -70,6 +77,7 @@ export const Config: z<Config> = z.object({
export function apply(ctx: Context, config: Config): void {
ctx.plugin(agentCore, {
...config.persona !== undefined ? { persona: config.persona } : {},
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
})
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
ctx.plugin(acp, { model: config.model })

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import * as acpAgent from '../src/index.ts'
/**
@@ -52,6 +53,27 @@ describe('dsh-acp-agent composition', () => {
expect(acpAgent.Config).toBeDefined()
})
it('forwards toolOrder through agent-core to the system-prompt assembly', async () => {
const ctx = await mount({
model: 'mock',
toolOrder: ['zulu', TOOL_ORDER_REST],
persistenceRoot: '/tmp/dsh-acp-agent-test-tool-order',
})
// The bundle's own bash tools pend on the absent `ctx.bash` executor in
// this providerless mount, so register two plain tools to order.
for (const name of ['alpha', 'zulu']) {
ctx.get('tools')!.register({
name,
description: name,
parameters: {},
execute: async () => [],
})
}
const assembly = await ctx.get('systemPrompt')!.assemble()
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha'])
await ctx.fiber.dispose()
})
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => {
// Postmortem 0001 guard: a stray `export default apply` makes the Loader's
// `unwrapExports` (`exports.default ?? exports`) collapse the module to the

View File

@@ -25,6 +25,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte
|---|---|---|
| `model` | (required) | the pre-created `main` agent's model |
| `persona` | — | the deployment persona template (may reference `{{model}}`), routed to `dsh-system-prompt` |
| `toolOrder` | — | explicit model-facing tool order (a name list with one `'<unlisted-tools>'` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` |
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
| `welcome` | `ready.` | the stdin-chat banner |
| `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) |

View File

@@ -50,6 +50,7 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-agent-core": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"cordis": "^4.0.0-rc.6",

View File

@@ -53,7 +53,8 @@ export const name = 'stdio-agent'
* App config: the swappable per-demo values, each routed to where the app wires
* it. `model`/`resumeSessionId` configure the pre-created `main` agent (through
* {@link @deepseek-ai/dsh-agent-core}'s forwarded `agents` list); `persona` is
* the deployment persona (forwarded to the system-prompt plugin);
* the deployment persona (forwarded to the system-prompt plugin); `toolOrder`
* is the explicit model-facing tool order (forwarded to the system-prompt plugin);
* `persistenceRoot` is the JSONL backend's directory; `welcome` is the UI banner.
*/
export interface Config {
@@ -61,6 +62,8 @@ export interface Config {
model: string
/** Deployment persona (the system-prompt plugin's `persona` config). */
persona?: string
/** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */
toolOrder?: string[]
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
persistenceRoot?: string
/** stdin-chat banner printed once on start. Defaults to `'ready.'`. */
@@ -76,6 +79,10 @@ export interface Config {
export const Config: z<Config> = z.object({
model: z.string().required(),
persona: z.string(),
// The array default is forced to undefined: ABSENT means "lexicographic
// order" (the owning dsh-system-prompt schema does the same), while
// schemastery's native [] default would read as an invalid configured list.
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
persistenceRoot: z.string().default('./.sessions'),
welcome: z.string().default('ready.'),
resumeSessionId: z.string(),
@@ -92,6 +99,7 @@ export function apply(ctx: Context, config: Config): void {
ctx.plugin(ConsoleExporter)
ctx.plugin(agentCore, {
...config.persona !== undefined ? { persona: config.persona } : {},
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
agents: [{
id: AgentId('main'),
model: config.model,

View File

@@ -76,9 +76,10 @@ async function makeConsumer(welcome: string, disabledBrokenEntry = false): Promi
await mkdir(dirname(target), { recursive: true })
await symlink(abs, target)
}
// The example's mock model + echo tool are example-local TS plugins (Node 24+
// strips types natively, so plain `node` loads them); they import the workspace
// packages the symlinked node_modules now provides.
// The example's mock model + echo tool are example-local TS plugins (Node
// 22.19+ — the engines floor — strips types natively, so plain `node` loads
// them); they import the workspace packages the symlinked node_modules now
// provides.
await cp(join(repoRoot, 'examples/echo-agent/src'), join(dir, 'src'), { recursive: true })
await writeFile(join(dir, 'cordis.yml'), [
'- id: mock-llm',

View File

@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import * as stdioAgent from '../src/index.ts'
/**
@@ -74,6 +75,27 @@ describe('dsh-stdio-agent app', () => {
expect(stdioAgent.Config).toBeDefined()
})
it('forwards toolOrder through agent-core to the system-prompt assembly', async () => {
const ctx = await mount({
model: 'mock',
toolOrder: ['zulu', TOOL_ORDER_REST],
persistenceRoot: '/tmp/dsh-stdio-agent-spec-tool-order',
})
// The bundle's own bash tools pend on the absent `ctx.bash` executor in
// this providerless mount, so register two plain tools to order.
for (const name of ['alpha', 'zulu']) {
ctx.get('tools')!.register({
name,
description: name,
parameters: {},
execute: async () => [],
})
}
const assembly = await ctx.get('systemPrompt')!.assemble()
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha'])
await ctx.fiber.dispose()
})
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => {
// Postmortem 0001 guard: a stray `export default apply` makes the Loader's
// `unwrapExports` (`exports.default ?? exports`) collapse the module to the

View File

@@ -1,6 +1,6 @@
/**
* `LocalFetchProvider`: a `WebFetchProvider` that retrieves a concrete public
* HTTP(S) URL with the platform-native `fetch` (Node 24) and returns a status
* HTTP(S) URL with platform-native `fetch` at the repo's Node floor and returns a status
* code plus bounded decoded content. It owns SAFE RESOURCE RETRIEVAL — URL
* validation, redirect policy, timeout, abort, byte caps, charset decoding,
* content-type classification, binary rejection — but NOT presentation

View File

@@ -12,7 +12,7 @@
* `web_search_tool_result` block (native search did not trigger), it throws
* `WEB_PROVIDER_ERROR` rather than degrading to prose-scraping.
*
* Network requests use platform-native `fetch` (Node 24), mirroring
* Network requests use platform-native `fetch` at the repo's Node floor, mirroring
* `@deepseek-ai/dsh-llm-deepseek`'s adapter — not a cordis HTTP-client service.
* The Anthropic wire shape is a provider-private detail and does NOT make this
* provider depend on `ctx.llm`.

View File

@@ -6,7 +6,7 @@
* `title`, the first highlight as `snippet`, and `publishedDate` as
* `publishedAt`.
*
* Network requests use platform-native `fetch` (Node 24), mirroring
* Network requests use platform-native `fetch` at the repo's Node floor, mirroring
* `@deepseek-ai/dsh-llm-deepseek`'s adapter — not a cordis HTTP-client service.
*
* @module @deepseek-ai/dsh-web-search-exa/provider

View File

@@ -5,7 +5,7 @@
* structured `search_results[]` for `sources[]`, falling back to the URL-only
* `citations[]` when `search_results` is absent.
*
* Network requests use platform-native `fetch` (Node 24), mirroring
* Network requests use platform-native `fetch` at the repo's Node floor, mirroring
* `@deepseek-ai/dsh-llm-deepseek`'s adapter. The OpenAI-compatible request shape
* is a provider-private detail and does NOT make this provider depend on
* `ctx.llm`.