feat: add canonical typed tool outputs

This commit is contained in:
Tianyi Cui
2026-07-21 03:08:35 +08:00
parent 8500974fd4
commit 66c36e7325
173 changed files with 3298 additions and 954 deletions

View File

@@ -8,7 +8,7 @@ The model-facing `ralph` tool runs a fixed foreground workflow that gives one im
Each child receives only the immutable objective, its current Ralph round and cap, a shared-workspace-as-authority instruction, and the previous structured handoff. The workspace is long-term memory; parent conversation and prior child sessions are not seeded. Reports have `status: continue | complete | blocked`, a non-empty summary, evidence, next steps, and blocker text. Status-specific semantics and the serialized `maxHandoffChars` ceiling are validated inside the fixed workflow and again at the consumer boundary. Invalid, missing, or oversized reports fail the workflow instead of being truncated or mistaken for cap exhaustion.
The successful terminal tool result is `complete`, `blocked`, or `budget-limited`, with the last bounded report and number of rounds started. Completion and blocker labels explicitly say that a worker reported the outcome; they are not independent certification. `maxResultChars` bounds the complete successful text including its envelope and truncation marker, without altering the validated report used as a cross-round handoff.
The successful terminal tool result is `complete`, `blocked`, or `budget-limited`, with the last bounded report and number of rounds started. The canonical envelope is `{ runId, agentsStarted, result }`; completion and blocker labels in its Native renderer explicitly say that a worker reported the outcome, not independent certification. `maxResultChars` bounds only that rendered text including its truncation marker, without altering the validated report in the canonical value or the cross-round handoff.
An ordinary child failure produces an error naming the failed round and retaining the last successful handoff when one exists. Ralph does not retry that round. Fatal provider-start, transport, worker, or workflow failures remain workflow errors and may settle before the fixed script can return a handoff. Cancellation is also an error; partial output is never success.

View File

@@ -8,6 +8,7 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { JsonValue } from '@deepseek-ai/dsh-session'
import type { SubagentProvider } from '@deepseek-ai/dsh-subagent'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools'
@@ -374,6 +375,13 @@ function renderResult(result: RalphRunResult, maxChars: number): string {
return boundResult(text, maxChars)
}
/** Canonical Ralph result fields shared by schema inference and rendering. */
const RALPH_OUTPUT_PROPERTIES = {
runId: { type: 'string', required: true },
agentsStarted: { type: 'integer', required: true },
result: { type: 'json', required: true },
} as const
/** Render an ordinary child failure with the most recent durable handoff. */
function renderRoundFailure(result: RalphRoundFailure, maxChars: number): string {
const header = `Ralph round ${result.roundsStarted} child failed before producing a structured report.`
@@ -415,7 +423,18 @@ export function apply(ctx: Context, config: Config): void {
description: 'Optional positive safe-integer round cap, bounded by the deployment ceiling.',
},
},
async execute(args, exec): Promise<ContentBlock[]> {
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: RALPH_OUTPUT_PROPERTIES,
},
render: (_args, value) => [{
type: 'text',
text: renderResult(value.result as unknown as RalphRunResult, resolved.maxResultChars),
}],
},
async execute(args, exec) {
const parent = exec.agent
if (parent === undefined) {
throw new Error('Ralph tool requires a calling agent (exec.agent was undefined)')
@@ -444,7 +463,11 @@ export function apply(ctx: Context, config: Config): void {
if (error !== undefined) throw new Error(error)
const value = readRunResult(settled.value, maxRounds, resolved.maxHandoffChars)
if (value.status === 'round-failed') throw new Error(renderRoundFailure(value, resolved.maxResultChars))
return [{ type: 'text', text: renderResult(value, resolved.maxResultChars) }]
return {
runId: run.id,
agentsStarted: settled.agentsStarted,
result: value as unknown as JsonValue,
}
} finally {
exec.signal?.removeEventListener('abort', onAbort)
await run.dispose()

View File

@@ -156,6 +156,12 @@ describe('dsh-tool-ralph', () => {
report: COMPLETE,
})
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected Ralph success')
expect(result.value).toEqual({
runId: 'ralph-1',
agentsStarted: 1,
result: { status: 'complete', roundsStarted: 1, report: COMPLETE },
})
expect((result.content[0] as { text: string }).text)
.toContain('Ralph worker reported completion after 1 round.')
expect((result.content[0] as { text: string }).text).toContain('All required gates pass.')
@@ -279,7 +285,7 @@ describe('dsh-tool-ralph', () => {
expect((await execute(ctx, { objective: 'Work.', maxRounds }, { agent: parent })).isError).toBe(true)
}
const missing = await execute(ctx, {}, { agent: parent })
expect(missing.error?.code).toBe('INVALID_ARGS')
expect(missing.error?.info?.code).toBe('INVALID_ARGS')
expect(engine.requests).toHaveLength(0)
})

View File

@@ -8,7 +8,7 @@ Three parameters: `meta` (required identity data: `name`, `description`, and opt
## Lifecycle
Collection is SYNCHRONOUS this cut (like [`dsh-tool-subagent`](../../subagent/tool-subagent/README.md)): `execute` starts a run and awaits `run.result` inside a `try/finally` that always disposes the run, so the script and its children reach quiescence on every path. `exec.signal` is bridged to `run.cancel()` (including the already-aborted-before-start case). A non-`completed` stop reason maps to an `isError` result reporting the reason — never partial output as success; a parse/meta failure thrown synchronously by `start()` becomes an `isError` the model can correct from. The completed result renders the meta name, the agent count, and the return value as JSON, truncated at `maxResultChars` with an explicit notice.
Collection is SYNCHRONOUS this cut (like [`dsh-tool-subagent`](../../subagent/tool-subagent/README.md)): `execute` starts a run and awaits `run.result` inside a `try/finally` that always disposes the run, so the script and its children reach quiescence on every path. `exec.signal` is bridged to `run.cancel()` (including the already-aborted-before-start case). A non-`completed` stop reason maps to an `isError` result reporting the reason—never partial output as success; a parse/meta failure thrown synchronously by `start()` becomes an `isError` the model can correct from. Completion returns canonical `{ runId, agentsStarted, result }`; the Native renderer preserves the meta name, agent count, and JSON value, truncating only that projection at `maxResultChars`.
## Render intent
@@ -74,5 +74,5 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work
- **The parent turn blocks until the whole workflow settles** — there is no background start/poll surface, and cancellation discards partial output as an error.
- **`args` must be an object and the result is bounded text** — callers wrap top-level arrays/scalars in a field, and JSON beyond `maxResultChars` is truncated rather than stored behind a retrieval handle.
- **`args` must be an object and Native result text is bounded** — callers wrap top-level arrays/scalars in a field; the canonical workflow result remains complete, while JSON beyond `maxResultChars` is truncated in the model-facing projection rather than stored behind a retrieval handle.
- **Workflow policy is fixed per tool registration** — provider selection, caps, and tool name are deployment config, not model-call arguments.

View File

@@ -15,6 +15,7 @@ import z from 'schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { JsonValue } from '@deepseek-ai/dsh-session'
import type { WorkflowResult, WorkflowRun } from '@deepseek-ai/dsh-workflow'
// Declaration merge only: makes ctx.systemPrompt visible for the section registration.
import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -100,13 +101,13 @@ function stopReasonError(result: WorkflowResult): string | undefined {
}
/** Render the run's outcome text: the meta name, agent count, and the JSON value (capped). */
function renderResult(run: WorkflowRun, result: WorkflowResult, maxChars: number): string {
function renderResult(name: string, agentsStarted: number, value: JsonValue, maxChars: number): string {
// The engine returns JSON data (null for a valueless script), so stringify never yields undefined.
const rendered = JSON.stringify(result.value, null, 2)
const rendered = JSON.stringify(value, null, 2)
const clipped = rendered.length > maxChars
? `${rendered.slice(0, maxChars)}\n… [truncated: ${rendered.length - maxChars} more characters]`
: rendered
return `workflow "${run.meta.name}" completed (${result.agentsStarted} agent${result.agentsStarted === 1 ? '' : 's'}).\nReturn value:\n${clipped}`
return `workflow "${name}" completed (${agentsStarted} agent${agentsStarted === 1 ? '' : 's'}).\nReturn value:\n${clipped}`
}
export function apply(ctx: Context, config: Config): void {
@@ -160,7 +161,22 @@ export function apply(ctx: Context, config: Config): void {
description: 'Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}).',
},
},
async execute(args, exec): Promise<ContentBlock[]> {
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
runId: { type: 'string', required: true },
agentsStarted: { type: 'integer', required: true },
result: { type: 'json', required: true },
},
},
render: (args, value) => [{
type: 'text',
text: renderResult(args.meta.name, value.agentsStarted, value.result, maxResultChars),
}],
},
async execute(args, exec) {
const parent = exec.agent
if (!parent) {
// The loop sets `exec.agent` for every model-driven call; its absence
@@ -197,7 +213,11 @@ export function apply(ctx: Context, config: Config): void {
// throw into an isError). Report the reason, not partial output.
throw new Error(error)
}
return [{ type: 'text', text: renderResult(run, result, maxResultChars) }]
return {
runId: run.id,
agentsStarted: result.agentsStarted,
result: result.value as JsonValue,
}
} finally {
exec.signal?.removeEventListener('abort', onAbort)
// Always reach run quiescence — never leak a live script or children.

View File

@@ -79,8 +79,10 @@ describe('dsh-tool-workflow', () => {
engine.settle({ value: { findings: [1, 2] }, stopReason: 'completed', agentsStarted: 7 })
const result = await pending
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected workflow success')
expect(result.value).toEqual({ runId: 'run-1', agentsStarted: 7, result: { findings: [1, 2] } })
const rendered = (result.content[0] as { text: string }).text
expect(rendered).toContain('workflow "stub-flow" completed (7 agents)')
expect(rendered).toContain('workflow "audit" completed (7 agents)')
expect(rendered).toContain('"findings"')
expect(engine.disposed).toBe(1)
})
@@ -151,7 +153,7 @@ describe('dsh-tool-workflow', () => {
const { ctx, parent } = await setup()
const result = await execute(ctx, {}, { agent: parent })
expect(result.isError).toBe(true)
expect(result.error?.code).toBe('INVALID_ARGS')
expect(result.error?.info?.code).toBe('INVALID_ARGS')
})
it('cancels the run when exec.signal is ALREADY aborted at call time', async () => {
@@ -169,7 +171,10 @@ describe('dsh-tool-workflow', () => {
const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
engine.settle({ value: { blob: 'x'.repeat(500) }, stopReason: 'completed', agentsStarted: 1 })
const rendered = ((await pending).content[0] as { text: string }).text
const result = await pending
if (result.isError) throw new Error('expected workflow success')
expect(result.value).toEqual({ runId: 'run-1', agentsStarted: 1, result: { blob: 'x'.repeat(500) } })
const rendered = (result.content[0] as { text: string }).text
expect(rendered).toContain('[truncated:')
expect(rendered.length).toBeLessThan(400)
})