feat: add canonical typed tool outputs
This commit is contained in:
@@ -60,7 +60,7 @@ Independent of the parent request cache. The child's later history is append-onl
|
||||
|
||||
#### What the model sees
|
||||
|
||||
A structured run adds the structured-output instruction below. It also adds a child-scoped `structured_output` definition with exact 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.` and the requested schema. This runtime-only definition is outside the generated shipped [tool package map](../../../docs/tool-catalog.md#tool-package-map). Success returns `Structured output recorded.`; a later call becomes ``Error: structured output already recorded: the run is complete, so `<tool>` is not executed``.
|
||||
A structured run adds the structured-output instruction below. It also adds a child-scoped `structured_output` definition with exact 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.` and the requested schema. This runtime-only definition is outside the generated shipped [tool package map](../../../docs/tool-catalog.md#tool-package-map). Its canonical acknowledgement is `{ recorded: true }`, rendered as `Structured output recorded.`; a later call becomes ``Error: structured output already recorded: the run is complete, so `<tool>` is not executed``.
|
||||
|
||||
##### Structured-output instruction
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { ContinuationStop } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import { ToolArgsError, validateJsonSchemaValue, type ObjectJsonSchema } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
@@ -74,7 +74,16 @@ export function attachStructuredRuntime(childCtx: Context, schema: ObjectJsonSch
|
||||
|
||||
childCtx.tools.register({
|
||||
...schemaEntry,
|
||||
execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]> {
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: { recorded: { type: 'boolean', const: true } },
|
||||
required: ['recorded'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
render: () => [{ type: 'text', text: 'Structured output recorded.' }],
|
||||
},
|
||||
execute(args: unknown, exec: ToolExecution): Promise<{ recorded: true }> {
|
||||
const violations = validateJsonSchemaValue(schema, args)
|
||||
// ToolArgsError → isError result with INVALID_ARGS: the model retries
|
||||
// within the same turn, exactly like a schema-validated defineTool call.
|
||||
@@ -83,7 +92,7 @@ export function attachStructuredRuntime(childCtx: Context, schema: ObjectJsonSch
|
||||
// waterfalls may still turn the success into an error. ToolRegistry has
|
||||
// already frozen model-bound arguments at the actual input boundary.
|
||||
staged.set(exec, { value: args })
|
||||
return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }])
|
||||
return Promise.resolve({ recorded: true })
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import type { Config as ToolConfig, ObjectJsonSchema } from '@deepseek-ai/dsh-tools'
|
||||
import { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
|
||||
import { defineContentToolFixture, RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { startInProcessRun } from '../src/index.ts'
|
||||
import {
|
||||
@@ -85,10 +85,15 @@ describe('in-process structured output', () => {
|
||||
const { ctx, parent } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42, note: 'done' }),
|
||||
])
|
||||
let acknowledgement: unknown
|
||||
ctx.on('tools/result', (exec, toolResult) => {
|
||||
if (exec.name === STRUCTURED_OUTPUT_TOOL && !toolResult.isError) acknowledgement = toolResult.value
|
||||
})
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(result.structured).toEqual({ answer: 42, note: 'done' })
|
||||
expect(acknowledgement).toEqual({ recorded: true })
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
@@ -119,15 +124,15 @@ describe('in-process structured output', () => {
|
||||
] as Script[number]
|
||||
const { ctx, parent } = await setup([response])
|
||||
let sideEffectRan = false
|
||||
ctx.tools.register({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'side_effect',
|
||||
description: 'probe',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
parameters: {},
|
||||
execute(): Promise<ContentBlock[]> {
|
||||
sideEffectRan = true
|
||||
return Promise.resolve([{ type: 'text', text: 'ran' }])
|
||||
},
|
||||
})
|
||||
}))
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
@@ -147,15 +152,15 @@ describe('in-process structured output', () => {
|
||||
] as Script[number]
|
||||
const { ctx, parent } = await setup([response])
|
||||
let sideEffectRan = false
|
||||
ctx.tools.register({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'side_effect',
|
||||
description: 'probe',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
parameters: {},
|
||||
execute(): Promise<ContentBlock[]> {
|
||||
sideEffectRan = true
|
||||
return Promise.resolve([{ type: 'text', text: 'ran' }])
|
||||
},
|
||||
})
|
||||
}))
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
// Registered after the child and prepended: this listener returns allow
|
||||
// after every downstream pre-execute decision. The service-owned guard
|
||||
@@ -184,15 +189,15 @@ describe('in-process structured output', () => {
|
||||
] as Script[number]
|
||||
const { ctx, parent } = await setup([response])
|
||||
let sideEffectRan = false
|
||||
ctx.tools.register({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'side_effect',
|
||||
description: 'probe',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
parameters: {},
|
||||
execute(): Promise<ContentBlock[]> {
|
||||
sideEffectRan = true
|
||||
return Promise.resolve([{ type: 'text', text: 'ran' }])
|
||||
},
|
||||
})
|
||||
}))
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const result = await run.result
|
||||
// The call ran BEFORE captured was set: the deny gate only guards the
|
||||
@@ -589,12 +594,12 @@ describe('in-process structured output', () => {
|
||||
])
|
||||
// A global tool sorts lexicographically after structured_output, while a
|
||||
// global section above the 190 band follows the capture instruction.
|
||||
ctx.tools.register({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'zz_probe',
|
||||
description: 'probe',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
parameters: {},
|
||||
execute: () => Promise.resolve([{ type: 'text', text: 'x' }]),
|
||||
})
|
||||
}))
|
||||
ctx.systemPrompt.section({ name: 'after-band', order: 200, text: 'AFTER-BAND' })
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
await run.result
|
||||
@@ -646,7 +651,7 @@ describe('in-process structured output', () => {
|
||||
agent: parent,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error?.code).toBe('UNKNOWN_TOOL')
|
||||
expect(result.error?.info?.code).toBe('UNKNOWN_TOOL')
|
||||
})
|
||||
|
||||
it('a structured_output call with NO calling agent at all is UNKNOWN_TOOL', async () => {
|
||||
@@ -657,7 +662,7 @@ describe('in-process structured output', () => {
|
||||
arguments: { answer: 1 },
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error?.code).toBe('UNKNOWN_TOOL')
|
||||
expect(result.error?.info?.code).toBe('UNKNOWN_TOOL')
|
||||
})
|
||||
|
||||
it('a failed execution stage is discarded and never promoted by a later call', async () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-sub
|
||||
import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import * as spawn from '../src/index.ts'
|
||||
import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess'
|
||||
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
||||
|
||||
@@ -383,10 +384,10 @@ describe('dsh-subagent-spawn', () => {
|
||||
toolCallResponse('c1', 'forbidden_tool', {}),
|
||||
textResponse('done'),
|
||||
])
|
||||
ctx.tools.register({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'forbidden_tool', description: 'global', parameters: {},
|
||||
execute: () => Promise.resolve([{ type: 'text', text: 'ran' }]),
|
||||
})
|
||||
}))
|
||||
const run = await start(ctx, 'spawn', {
|
||||
prompt: [{ type: 'text', text: 'do X' }],
|
||||
parent,
|
||||
|
||||
@@ -6,9 +6,9 @@ The model-facing delegation tool over one configured `ctx.subagents` provider. C
|
||||
|
||||
Each plugin instance binds one `provider` to one `toolName`; the model receives no provider selector. Load another distinctly named instance to expose another transport. The tool registers only while its provider exists, avoiding sibling load-order and provider-reload dependencies. Its description follows `provider.inheritsParentContext`: fresh children require standalone prompts, while forked children already see completed parent turns.
|
||||
|
||||
A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns final text; abort, refusal, token limit, and other failures become errored tool results without partial output.
|
||||
A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text; abort, refusal, token limit, and other failures become errored tool results without partial output.
|
||||
|
||||
With `run_in_background: true`, the tool registers the parent-owned task before starting the provider. A task-owned signal covers pending startup and the child after the starting call returns. `task_kill` and owner disposal abort it. Settlement awaits startup rollback or child disposal, then maps completed final text, abort to `killed`, and other failures to `failed`. The task has no incremental read; generic task tools own later status, collection, cancellation, and notices. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md).
|
||||
With `run_in_background: true`, the tool registers the parent-owned task before starting the provider and returns canonical `{ kind: 'background', taskId }`, rendered as `started background subagent task <id>`. A task-owned signal covers pending startup and the child after the starting call returns. `task_kill` and owner disposal abort it. Settlement awaits startup rollback or child disposal, then maps completed final text, abort to `killed`, and other failures to `failed`. The task has no incremental read; generic task tools own later status, collection, cancellation, and notices. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md).
|
||||
|
||||
`toolFilter` changes the child's global tool layer but is not a parent-derived authority ceiling. See the [agent-scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals).
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import z from 'schemastery'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||
import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import type { TaskOutcome } from '@deepseek-ai/dsh-tasks'
|
||||
@@ -95,6 +96,16 @@ function outputText(blocks: ContentBlock[]): string {
|
||||
.join('')
|
||||
}
|
||||
|
||||
/** Render text blocks from the canonical JSON block array without trusting arbitrary values. */
|
||||
function outputValueText(values: JsonValue[]): string {
|
||||
return values
|
||||
.filter((value): value is { type: 'text'; text: string } =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
&& value.type === 'text' && typeof value.text === 'string')
|
||||
.map(value => value.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
/** A non-`completed` stop reason means the child did not finish cleanly. */
|
||||
function stopReasonError(result: SubagentResult): string | undefined {
|
||||
switch (result.stopReason) {
|
||||
@@ -268,7 +279,36 @@ export function apply(ctx: Context, config: Config): void {
|
||||
},
|
||||
} : {},
|
||||
},
|
||||
async execute(args, exec): Promise<ContentBlock[]> {
|
||||
output: {
|
||||
schema: {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
kind: { type: 'string', required: true, const: 'background' },
|
||||
taskId: { type: 'string', required: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
kind: { type: 'string', required: true, const: 'foreground' },
|
||||
runId: { type: 'string', required: true },
|
||||
output: { type: 'array', required: true, items: { type: 'json' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
render: (_args, value) => [{
|
||||
type: 'text',
|
||||
text: value.kind === 'background'
|
||||
? `started background subagent task ${value.taskId}`
|
||||
: outputValueText(value.output),
|
||||
}],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const parent = exec.agent
|
||||
if (!parent) {
|
||||
// Non-agent callers provide no parent for delegation ownership.
|
||||
@@ -308,7 +348,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
},
|
||||
})
|
||||
return [{ type: 'text', text: `started background subagent task ${id}` }]
|
||||
return { kind: 'background' as const, taskId: id }
|
||||
}
|
||||
|
||||
const request = startRequest(
|
||||
@@ -327,7 +367,13 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// The registry converts this throw to isError; partial output is not success.
|
||||
throw new Error(error)
|
||||
}
|
||||
return [{ type: 'text', text: outputText(result.output) }]
|
||||
return {
|
||||
kind: 'foreground' as const,
|
||||
runId: run.id,
|
||||
// Content blocks already cross durable JSON boundaries elsewhere;
|
||||
// the registry performs the authoritative lossless snapshot here.
|
||||
output: result.output as unknown as JsonValue[],
|
||||
}
|
||||
} finally {
|
||||
// Dispose before returning so no child session outlives the call.
|
||||
await run.dispose()
|
||||
|
||||
@@ -62,6 +62,12 @@ describe('dsh-tool-subagent', () => {
|
||||
const ctx = await setup({ provider: 'mock' }, { reply: 'child says hi' })
|
||||
const result = await callSubagent(ctx, { description: 'do a thing', prompt: 'go research X' })
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected subagent success')
|
||||
expect(result.value).toEqual({
|
||||
kind: 'foreground',
|
||||
runId: 'scripted-subagent:mock:parent-1',
|
||||
output: [{ type: 'text', text: 'child says hi' }],
|
||||
})
|
||||
expect(text(result)).toBe('child says hi')
|
||||
})
|
||||
|
||||
@@ -637,6 +643,8 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
|
||||
const start = await callSubagent(ctx, { description: 'deep research', prompt: 'dig in', run_in_background: true }, { agent: parent })
|
||||
expect(start.isError).toBe(false)
|
||||
if (start.isError) throw new Error('expected background subagent success')
|
||||
expect(start.value).toEqual({ kind: 'background', taskId: 'subagent-1' })
|
||||
expect(text(start)).toBe('started background subagent task subagent-1')
|
||||
|
||||
const collected = await ctx.tools.execute({
|
||||
|
||||
Reference in New Issue
Block a user