Merge commit 'refs/codex-unblock/20260723/master' into worktree/pty-review-fixes
# Conflicts: # .agents/notes/implemented/feature/2026-06-30-interception-seams.md # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/core-data-structures/tools.md # docs/event-producer-consumer.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/tools/README.md # packages/core/tools/src/index.ts # packages/core/tools/src/schema.ts # packages/core/tools/tests/tools.spec.ts # packages/pty/tool-pty/README.md # packages/pty/tool-pty/src/index.ts # packages/pty/tool-pty/src/render.ts # packages/tasks/tool-tasks/README.md # packages/tasks/tool-tasks/src/index.ts
This commit is contained in:
@@ -6,11 +6,11 @@
|
||||
*/
|
||||
|
||||
import { parse } from 'node:path'
|
||||
import { inspect } from 'node:util'
|
||||
import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { CodeBindingFunction, CodeRunResult, CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type {} from '@deepseek-ai/dsh-session'
|
||||
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||
import { defineTool } from './schema.ts'
|
||||
import type { ToolDefinition, ToolRegistry } from './index.ts'
|
||||
|
||||
@@ -62,10 +62,7 @@ export class CodeRunFailedError extends HarnessError {
|
||||
*/
|
||||
const SUMMARY_MAX_CHARS = 200
|
||||
|
||||
/** Bounded inspect for rendering a program's completion value into the model-facing text. */
|
||||
const INSPECT_OPTIONS = { depth: 4, maxArrayLength: 100, maxStringLength: 10_000 } as const
|
||||
|
||||
/** Join a result's text blocks; a non-text block becomes a placeholder (an MVP limitation, stated in the SDK instructions). */
|
||||
/** Join Native content for the bounded durable sub-dispatch summary; non-text blocks become diagnostic placeholders. */
|
||||
function textOf(content: ContentBlock[]): string {
|
||||
return content
|
||||
.map((block) => {
|
||||
@@ -88,47 +85,120 @@ function summarize(text: string, cwd: string | undefined): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-normalize one binding call's argument into TWO independent parses of the same canonical
|
||||
* text: `dispatched` goes to the tool, `logged` to the `tool/code-dispatch` event — identical
|
||||
* by construction (the runtime's structured-clone boundary is wider than JSON; the session log
|
||||
* accepts only JSON), and separate objects, so a tool mutating its args can neither desync the
|
||||
* log from what was dispatched nor re-poison the append.
|
||||
* Snapshot one binding call's argument as lossless JSON, then snapshot that
|
||||
* detached value again so dispatch and logging stay independent without
|
||||
* reintroducing structured-clone's platform-specific nesting limit.
|
||||
*/
|
||||
function jsonNormalizeArgs(value: unknown): { dispatched: unknown; logged: unknown } {
|
||||
if (value === undefined) {
|
||||
throw new Error('tool arguments must be JSON-serializable (call the tool with an arguments object, e.g. `{}`)')
|
||||
}
|
||||
let text: string | undefined
|
||||
let snapshot: JsonValue | undefined
|
||||
try {
|
||||
text = JSON.stringify(value)
|
||||
snapshot = snapshotJsonValue(value) as JsonValue | undefined
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`tool arguments must be JSON-serializable: ${error instanceof Error ? error.message : String(error)}`)
|
||||
throw new Error(`tool arguments must be lossless JSON: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
// JSON.stringify's lib type claims `string`, but a bare function or symbol
|
||||
// root really yields `undefined` at runtime — the guard is live.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (text === undefined) throw new Error('tool arguments must be JSON-serializable (got a value JSON cannot represent)')
|
||||
return { dispatched: JSON.parse(text) as unknown, logged: JSON.parse(text) as unknown }
|
||||
if (snapshot === undefined) {
|
||||
throw new Error('tool arguments must be lossless JSON (call the tool with an arguments object, e.g. `{}`)')
|
||||
}
|
||||
const logged = snapshotJsonValue(snapshot)
|
||||
/* v8 ignore next -- snapshot is already a detached lossless JSON value. */
|
||||
if (logged === undefined) {
|
||||
throw new Error('tool arguments could not be detached for durable logging')
|
||||
}
|
||||
return { dispatched: snapshot, logged }
|
||||
}
|
||||
|
||||
/** Render the program's completion value for the model-facing result text (`''` when the program returned nothing). */
|
||||
function renderValue(value: unknown): string {
|
||||
if (value === undefined) return ''
|
||||
return typeof value === 'string' ? value : inspect(value, INSPECT_OPTIONS)
|
||||
/** Two-space JSON presentation, matching the existing shallow `run_code` text contract. */
|
||||
const JSON_INDENT = ' '
|
||||
|
||||
/**
|
||||
* ECMAScript caps `JSON.stringify`'s `space` string at ten characters. The
|
||||
* renderer also caps TOTAL indentation there, compacting deeper subtrees, so
|
||||
* formatted output remains linear in the canonical JSON size.
|
||||
*/
|
||||
const MAX_JSON_INDENT_CHARS = 10
|
||||
|
||||
/** A pending fragment in the iterative JSON presentation traversal. */
|
||||
type JsonRenderTask =
|
||||
| { kind: 'text'; text: string }
|
||||
| { kind: 'value'; value: JsonValue; depth: number; compact: boolean }
|
||||
|
||||
/** Render one non-string JSON root without recursive traversal or unbounded indentation growth. */
|
||||
function renderJsonValue(value: Exclude<JsonValue, string>): string {
|
||||
const chunks: string[] = []
|
||||
const tasks: JsonRenderTask[] = [{ kind: 'value', value, depth: 0, compact: false }]
|
||||
for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
|
||||
if (task.kind === 'text') {
|
||||
chunks.push(task.text)
|
||||
continue
|
||||
}
|
||||
|
||||
const current = task.value
|
||||
if (current === null || typeof current === 'boolean' || typeof current === 'number') {
|
||||
chunks.push(String(current))
|
||||
continue
|
||||
}
|
||||
if (typeof current === 'string') {
|
||||
chunks.push(JSON.stringify(current))
|
||||
continue
|
||||
}
|
||||
|
||||
const compact = task.compact || (task.depth + 1) * JSON_INDENT.length > MAX_JSON_INDENT_CHARS
|
||||
const childDepth = task.depth + 1
|
||||
if (Array.isArray(current)) {
|
||||
chunks.push('[')
|
||||
if (current.length === 0) {
|
||||
chunks.push(']')
|
||||
continue
|
||||
}
|
||||
tasks.push({ kind: 'text', text: compact ? ']' : `\n${JSON_INDENT.repeat(task.depth)}]` })
|
||||
for (let index = current.length - 1; index >= 0; index--) {
|
||||
const item = current[index]
|
||||
/* v8 ignore next -- canonical JsonValue arrays are dense. */
|
||||
if (item === undefined) throw new Error('cannot render a sparse JSON array')
|
||||
tasks.push({ kind: 'value', value: item, depth: childDepth, compact })
|
||||
tasks.push({
|
||||
kind: 'text',
|
||||
text: compact
|
||||
? index === 0 ? '' : ','
|
||||
: `${index === 0 ? '\n' : ',\n'}${JSON_INDENT.repeat(childDepth)}`,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const keys = Object.keys(current)
|
||||
chunks.push('{')
|
||||
if (keys.length === 0) {
|
||||
chunks.push('}')
|
||||
continue
|
||||
}
|
||||
tasks.push({ kind: 'text', text: compact ? '}' : `\n${JSON_INDENT.repeat(task.depth)}}` })
|
||||
for (let index = keys.length - 1; index >= 0; index--) {
|
||||
const key = keys[index]
|
||||
/* v8 ignore next -- the loop is bounded by the captured key count. */
|
||||
if (key === undefined) throw new Error('cannot render a missing JSON object key')
|
||||
const item = current[key]
|
||||
/* v8 ignore next -- canonical JsonValue records contain no undefined properties. */
|
||||
if (item === undefined) throw new Error('cannot render an undefined JSON object property')
|
||||
tasks.push({ kind: 'value', value: item, depth: childDepth, compact })
|
||||
tasks.push({
|
||||
kind: 'text',
|
||||
text: compact
|
||||
? `${index === 0 ? '' : ','}${JSON.stringify(key)}:`
|
||||
: `${index === 0 ? '\n' : ',\n'}${JSON_INDENT.repeat(childDepth)}${JSON.stringify(key)}: `,
|
||||
})
|
||||
}
|
||||
}
|
||||
return chunks.join('')
|
||||
}
|
||||
|
||||
/** The run_code result's `meta` payload (JSON-serializable; `presentResult` narrows it back). */
|
||||
interface RunCodeMeta {
|
||||
logs: CodeRunResult['logs']
|
||||
/** Render one present program completion value for the model-facing result text. */
|
||||
function renderValue(value: JsonValue): string {
|
||||
return typeof value === 'string' ? value : renderJsonValue(value)
|
||||
}
|
||||
|
||||
/** Soft-narrow a result `meta` back to {@link RunCodeMeta} (replay may carry older shapes; presentation must not throw). */
|
||||
function asRunCodeMeta(meta: unknown): RunCodeMeta | undefined {
|
||||
if (typeof meta !== 'object' || meta === null) return undefined
|
||||
const m = meta as Record<string, unknown>
|
||||
if (!Array.isArray(m.logs) || !m.logs.every(log => typeof log === 'string')) return undefined
|
||||
return m as unknown as RunCodeMeta
|
||||
}
|
||||
/** Canonical value returned by the outer Code Mode transport. */
|
||||
type RunCodeOutput = { logs: string[]; result?: JsonValue }
|
||||
|
||||
/**
|
||||
* Build the `run_code` {@link ToolDefinition}: one required `code` parameter,
|
||||
@@ -152,7 +222,22 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
|
||||
parameters: {
|
||||
code: { type: 'string', required: true, description: 'The program: the body of an async TypeScript function.' },
|
||||
},
|
||||
async execute(args, exec) {
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
logs: { type: 'array', required: true, items: { type: 'string' } },
|
||||
result: { type: 'json' },
|
||||
},
|
||||
},
|
||||
render: (_args, value) => {
|
||||
const rendered = value.result === undefined ? '' : renderValue(value.result)
|
||||
const parts = [value.logs.join('\n'), rendered].filter(part => part.length > 0)
|
||||
return [{ type: 'text', text: parts.length > 0 ? parts.join('\n') : '(run_code completed with no output)' }]
|
||||
},
|
||||
},
|
||||
async execute(args, exec): Promise<RunCodeOutput> {
|
||||
const runtime = requireRuntime()
|
||||
|
||||
// The run-scoped abort: follows the outer signal in, and fires when the
|
||||
@@ -184,7 +269,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
|
||||
// would be narrowed away by control flow analysis.
|
||||
const runOver = (): boolean => runController.signal.aborted
|
||||
|
||||
const binding = (name: string): CodeBindingFunction => async (rawArgs: unknown): Promise<unknown> => {
|
||||
const binding = (name: string): CodeBindingFunction => async (rawArgs: unknown): Promise<JsonValue> => {
|
||||
if (runOver()) {
|
||||
throw new Error(`run_code run is over (${String(runController.signal.reason)}); ${name} not dispatched`)
|
||||
}
|
||||
@@ -215,7 +300,9 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
|
||||
isError: result.isError,
|
||||
resultSummary: summarize(text, exec.agent.session.header.cwd),
|
||||
})
|
||||
return { text, isError: result.isError }
|
||||
return result.isError
|
||||
? { isError: true as const, message: result.error.message }
|
||||
: { isError: false as const, value: result.value }
|
||||
})
|
||||
// A budget expiry or outer cancel that lands while this call was in
|
||||
// flight already aborted the dispatch; stop the program now rather
|
||||
@@ -223,11 +310,11 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
|
||||
if (runOver()) {
|
||||
throw new Error(`run_code run is over (${String(runController.signal.reason)}); ${name} result discarded`)
|
||||
}
|
||||
// A failed tool call REJECTS — real code signals failure by throwing,
|
||||
// so try/catch and Promise.all short-circuiting behave as models
|
||||
// expect (the error text is the tool's model-facing result text).
|
||||
if (outcome.isError) throw new Error(outcome.text)
|
||||
return outcome.text
|
||||
// The worker turns a binding rejection into ToolCallError and adds
|
||||
// only the binding name. Native content and internal error metadata
|
||||
// stay outside the program-facing failure contract.
|
||||
if (outcome.isError) throw new Error(outcome.message)
|
||||
return outcome.value
|
||||
}
|
||||
|
||||
// Null-prototype + defineProperty, mirroring the worker-side namespace
|
||||
@@ -250,7 +337,11 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
|
||||
try {
|
||||
result = await runtime.run({
|
||||
program: args.code,
|
||||
bindings: [{ global: 'tools', functions }],
|
||||
bindings: [{
|
||||
global: 'tools',
|
||||
functions,
|
||||
errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' },
|
||||
}],
|
||||
signal: runController.signal,
|
||||
})
|
||||
} finally {
|
||||
@@ -264,12 +355,9 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
|
||||
const logsText = result.logs.length > 0 ? `\nCaptured output:\n${result.logs.join('\n')}` : ''
|
||||
throw new CodeRunFailedError(`code run failed (${result.error.kind}): ${result.error.message}${logsText}`)
|
||||
}
|
||||
const rendered = renderValue(result.value)
|
||||
const parts = [result.logs.join('\n'), rendered].filter(part => part.length > 0)
|
||||
const meta: RunCodeMeta = { logs: result.logs }
|
||||
return {
|
||||
content: [{ type: 'text', text: parts.length > 0 ? parts.join('\n') : '(run_code completed with no output)' }],
|
||||
meta,
|
||||
logs: result.logs,
|
||||
...result.value !== undefined ? { result: result.value } : {},
|
||||
}
|
||||
} finally {
|
||||
exec.signal.removeEventListener('abort', onOuterAbort)
|
||||
@@ -282,17 +370,8 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
|
||||
kind: 'execute',
|
||||
rawInput: args.code,
|
||||
}),
|
||||
// Title omitted on the result: an update replaces only the fields it
|
||||
// carries, so the pending card's program title persists through
|
||||
// completion; the captured output rides as body content.
|
||||
presentResult: (_args, result) => {
|
||||
const meta = asRunCodeMeta(result.meta)
|
||||
if (!meta) return undefined
|
||||
const output = meta.logs.join('\n')
|
||||
return {
|
||||
card: 'generic',
|
||||
...output.length > 0 ? { content: [{ type: 'text' as const, text: output }] } : {},
|
||||
}
|
||||
},
|
||||
// Deliberately no presentResult: the generic surface fallback keeps this
|
||||
// title and reads durable result content without duplicating a large raw
|
||||
// result into the host view payload.
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,40 +12,60 @@ import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import { assertNever, deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
|
||||
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolProviderResult } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
// Type-only: makes `ctx.get('approval')` resolve to the ApprovalService
|
||||
// augmentation. The seam stays optional at runtime — see `serviceAsk`.
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import type { ToolCallView, ToolResultView } from './presentation.ts'
|
||||
import { assertSupportedJsonSchema, validateJsonSchemaValue } from './json-schema.ts'
|
||||
import type { JsonSchemaNode } from './json-schema.ts'
|
||||
import { createRunCodeTool, RUN_CODE_NAME, SDK_SECTION_ORDER } from './code-mode.ts'
|
||||
import { renderToolsSdk } from './ts-types.ts'
|
||||
import type { ToolSdkSchema } from './ts-types.ts'
|
||||
|
||||
export {
|
||||
defineTool,
|
||||
schemaSpecToJsonSchema,
|
||||
valueSchemaSpecToJsonSchema,
|
||||
parameterSchemaSpecToJsonSchema,
|
||||
validateArgs,
|
||||
ToolArgsError,
|
||||
type SchemaSpec,
|
||||
type SchemaProp,
|
||||
type SchemaType,
|
||||
type ValueSchemaAnnotations,
|
||||
type StringValueSchemaSpec,
|
||||
type NumberValueSchemaSpec,
|
||||
type IntegerValueSchemaSpec,
|
||||
type BooleanValueSchemaSpec,
|
||||
type NullValueSchemaSpec,
|
||||
type ArrayValueSchemaSpec,
|
||||
type ObjectValueSchemaSpec,
|
||||
type JsonValueSchemaSpec,
|
||||
type OneOfValueSchemaSpec,
|
||||
type ValueSchemaSpec,
|
||||
type ParameterPropertySpec,
|
||||
type ParameterSchemaSpec,
|
||||
type ParameterJsonSchema,
|
||||
type InferValue,
|
||||
type InferArgs,
|
||||
type DefineToolOptions,
|
||||
type JsonSchemaObject,
|
||||
} from './schema.ts'
|
||||
|
||||
export {
|
||||
assertSupportedOutputSchema,
|
||||
validateStructuredValue,
|
||||
OutputSchemaError,
|
||||
type StructuredOutputSchema,
|
||||
type StructuredSchemaNode,
|
||||
type StructuredSchemaType,
|
||||
type StructuredScalar,
|
||||
assertSupportedJsonSchema,
|
||||
assertObjectJsonSchema,
|
||||
validateJsonSchemaValue,
|
||||
JsonSchemaError,
|
||||
type JsonSchemaNode,
|
||||
type ObjectJsonSchema,
|
||||
type JsonSchemaType,
|
||||
type JsonSchemaScalar,
|
||||
} from './json-schema.ts'
|
||||
|
||||
export type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||
|
||||
export { CodeRunFailedError, RUN_CODE_NAME } from './code-mode.ts'
|
||||
export { jsonSchemaToTs, renderToolsSdk } from './ts-types.ts'
|
||||
export { defineContentToolFixture, type ContentToolFixtureOptions } from './testing.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`
|
||||
@@ -124,21 +144,31 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Tool output, optionally with lossless-JSON presentation metadata persisted for replay. */
|
||||
export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown }
|
||||
/** Tool-owned canonical output contract used after the body returns a JSON value. */
|
||||
export interface ToolOutputDefinition {
|
||||
/** Raw supported JSON Schema enforced against every successful canonical value. */
|
||||
readonly schema: JsonSchemaNode
|
||||
/** Pure projection from validated arguments and value to Native/model content. */
|
||||
render(args: unknown, value: JsonValue): ContentBlock[]
|
||||
/** Pure replayable presentation projection, computed only for surface calls. */
|
||||
presentationMeta?(args: unknown, value: JsonValue): JsonValue
|
||||
}
|
||||
|
||||
/** A registered tool: its schema plus the execution function. */
|
||||
export interface ToolDefinition extends ToolSchema {
|
||||
/** Mandatory canonical output declaration. */
|
||||
readonly output: ToolOutputDefinition
|
||||
/**
|
||||
* Run one accepted call. Async work must observe or forward `exec.signal` and
|
||||
* settle only after its owned work reaches quiescence. The registry preserves
|
||||
* caller cancellation through around-dispatch signal replacement and does
|
||||
* not abandon this promise, but it cannot hard-kill same-process code.
|
||||
* Run one accepted call and return only its canonical lossless-JSON value.
|
||||
* Async work must observe or forward `exec.signal` and settle only after its
|
||||
* owned work reaches quiescence. The registry preserves caller cancellation
|
||||
* through around-dispatch signal replacement and does not abandon this
|
||||
* promise, but it cannot hard-kill same-process code.
|
||||
* @param args - losslessly snapshotted, frozen model arguments.
|
||||
* @param exec - execution identity, cancellation signal, and context deferral.
|
||||
* @returns model-facing content plus optional private presentation metadata.
|
||||
* @returns the canonical value declared by `output.schema`.
|
||||
*/
|
||||
execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn>
|
||||
execute(args: unknown, exec: ToolRunContext): Promise<unknown>
|
||||
/**
|
||||
* Synchronous last-mile transform for model-facing content. The registry
|
||||
* snapshots this callback when execution starts and invokes it exactly once
|
||||
@@ -185,7 +215,7 @@ export interface ToolDefinition extends ToolSchema {
|
||||
presentCall?(args: unknown): ToolCallView | undefined
|
||||
/**
|
||||
* Optional: how to present the COMPLETED state, given the same `args` and the
|
||||
* `result` (`execute`'s content + whether it errored). Returns a
|
||||
* durable result projection (`content`, failure state, and optional `meta`). Returns a
|
||||
* {@link ToolResultView}, or `undefined` (or omit the method) to keep the
|
||||
* pending title and render the raw result content. Pure and side-effect-free
|
||||
* for the same replay reason.
|
||||
@@ -195,17 +225,16 @@ export interface ToolDefinition extends ToolSchema {
|
||||
|
||||
/** The completed outcome handed to {@link ToolDefinition.presentResult}. */
|
||||
export interface ToolResult {
|
||||
/** The model-facing content `execute` returned (or the error text on failure). */
|
||||
/** The final model-facing content (or the rendered error text on failure). */
|
||||
content: ContentBlock[]
|
||||
/** Whether the call failed. */
|
||||
isError: boolean
|
||||
/**
|
||||
* The tool-private presentation payload the tool attached from `execute` (via
|
||||
* the object return form), threaded verbatim from the `tool/result` event.
|
||||
* Opaque (`unknown`); the tool narrows it back to its own shape. Absent when
|
||||
* the tool attached none.
|
||||
* The tool-private presentation payload projected by its output declaration
|
||||
* and threaded verbatim from the `tool/result` event. Absent when the tool
|
||||
* declared no projector or the call was nested under a composite transport.
|
||||
*/
|
||||
meta?: unknown
|
||||
meta?: JsonValue
|
||||
}
|
||||
|
||||
declare const toolExecutionTokenBrand: unique symbol
|
||||
@@ -337,6 +366,14 @@ export interface ToolErrorInfo {
|
||||
code: string
|
||||
}
|
||||
|
||||
/** Canonical failure detail; internal routing information remains optional. */
|
||||
export interface ToolFailure {
|
||||
/** Human-readable failure message without the Native `Error: ` envelope. */
|
||||
message: string
|
||||
/** Internal error class/code used by policy and durable diagnostics. */
|
||||
info?: ToolErrorInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown (internally) when the model requests a tool that isn't registered.
|
||||
* Extends {@link HarnessError} (`code: 'UNKNOWN_TOOL'`) so an unknown-tool
|
||||
@@ -350,30 +387,73 @@ export class ToolNotFoundError extends HarnessError {
|
||||
}
|
||||
}
|
||||
|
||||
/** The outcome of one tool call. */
|
||||
export interface ToolExecutionResult {
|
||||
content: ContentBlock[]
|
||||
isError: boolean
|
||||
/**
|
||||
* Set when the call failed with a {@link HarnessError}: machine-routable
|
||||
* `{ name, code }` for retry/sandbox plugins and replay. The model-facing
|
||||
* text in `content` is always present; this is extra structure for code.
|
||||
*/
|
||||
error?: ToolErrorInfo
|
||||
/**
|
||||
* Model-facing context for the next request, separate from this tool result. The loop
|
||||
* accepts it into the active-batch FIFO, then appends after recorded results even if interrupted.
|
||||
*/
|
||||
additionalContexts?: HookContext[]
|
||||
/**
|
||||
* The tool-private presentation payload from a successful `execute` (the object
|
||||
* return form). Threaded onto the `tool/result` session event and back into
|
||||
* {@link ToolResult} for `presentResult`. Opaque (`unknown`); absent when the
|
||||
* tool attached none or the call failed.
|
||||
*/
|
||||
meta?: unknown
|
||||
/** Thrown when a tool body or post-policy value violates its declared output. */
|
||||
export class ToolOutputError extends HarnessError {
|
||||
/** Schema/value violations in validation order. */
|
||||
readonly violations: string[]
|
||||
|
||||
constructor(toolName: string, violations: string[]) {
|
||||
super(`tool "${toolName}" returned invalid output: ${violations.join('; ')}`, 'INVALID_TOOL_OUTPUT')
|
||||
this.name = 'ToolOutputError'
|
||||
this.violations = violations
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert one projector exception into the canonical invalid-output failure. */
|
||||
function projectionError(toolName: string, projector: 'render' | 'presentationMeta', error: unknown): ToolOutputError {
|
||||
return new ToolOutputError(toolName, [`output.${projector} failed: ${errorMessage(error)}`])
|
||||
}
|
||||
|
||||
/** Snapshot one projector result before later durable-result materialization. */
|
||||
function snapshotProjection<T>(toolName: string, projector: 'render' | 'presentationMeta', candidate: T): T {
|
||||
try {
|
||||
const detached = snapshotJsonValue(candidate)
|
||||
if (detached === undefined) {
|
||||
throw new ToolOutputError(toolName, [`output.${projector} returned non-lossless JSON`])
|
||||
}
|
||||
return detached
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ToolOutputError) throw error
|
||||
throw projectionError(toolName, projector, error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Snapshot one body or policy value into the canonical invalid-output failure class. */
|
||||
function snapshotToolValue(toolName: string, candidate: unknown): JsonValue {
|
||||
try {
|
||||
const detached = snapshotJsonValue(candidate)
|
||||
if (detached === undefined) throw new ToolOutputError(toolName, ['value is not lossless JSON'])
|
||||
return detached as JsonValue
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ToolOutputError) throw error
|
||||
throw new ToolOutputError(toolName, [`value snapshot failed: ${errorMessage(error)}`])
|
||||
}
|
||||
}
|
||||
|
||||
/** Successful canonical tool execution, including its Native/model projection. */
|
||||
export interface ToolExecutionSuccess {
|
||||
readonly isError: false
|
||||
/** Execution-local canonical value; deliberately omitted from durable events. */
|
||||
readonly value: JsonValue
|
||||
readonly content: ContentBlock[]
|
||||
readonly error?: never
|
||||
readonly meta?: JsonValue
|
||||
readonly additionalContexts?: HookContext[]
|
||||
}
|
||||
|
||||
/** Failed canonical tool execution; failures never carry a successful value. */
|
||||
export interface ToolExecutionFailure {
|
||||
readonly isError: true
|
||||
readonly error: ToolFailure
|
||||
readonly value?: never
|
||||
readonly content: ContentBlock[]
|
||||
readonly meta?: JsonValue
|
||||
readonly additionalContexts?: HookContext[]
|
||||
}
|
||||
|
||||
/** The discriminated, execution-local outcome of one tool call. */
|
||||
export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure
|
||||
|
||||
/**
|
||||
* Pre-dispatch decision. `allow` runs the call; `deny` materializes an error;
|
||||
* `ask` runs only after an approval service returns `allowed-once` and otherwise
|
||||
@@ -386,11 +466,12 @@ export type PreToolDecision =
|
||||
| { kind: 'ask'; reason?: string }
|
||||
|
||||
/**
|
||||
* Post-dispatch decision: accept or replace content, attach context for the next
|
||||
* request, or block by turning corrective feedback into an error result.
|
||||
* Post-dispatch decision: accept, replace one projection, attach context for the
|
||||
* next request, or block by turning corrective feedback into an error result.
|
||||
*/
|
||||
export type PostToolDecision =
|
||||
| { kind: 'accept'; content?: ContentBlock[]; additionalContexts?: HookContext[] }
|
||||
| { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: HookContext[] }
|
||||
| { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: HookContext[] }
|
||||
| { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: HookContext[] }
|
||||
|
||||
/**
|
||||
@@ -415,6 +496,23 @@ function errorMessage(error: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Derive one failure message from policy feedback without changing its rendered blocks. */
|
||||
function failureMessageFromContent(content: ContentBlock[]): string {
|
||||
const text = content
|
||||
.map(block => block.type === 'text' ? block.text : `[${block.type} content]`)
|
||||
.join('\n')
|
||||
return text.length > 0 ? text : 'tool result blocked by post-execute policy'
|
||||
}
|
||||
|
||||
/** Snapshot and freeze one durable tool-result projection or reject lossy data. */
|
||||
function materializePresentation<T>(candidate: T): T {
|
||||
const detached = snapshotJsonValue(candidate)
|
||||
if (detached === undefined) {
|
||||
throw new TypeError('tool result must be losslessly JSON-serializable')
|
||||
}
|
||||
return deepFreeze(detached)
|
||||
}
|
||||
|
||||
/** Structured `{ name, code }` for a thrown HarnessError, else undefined. */
|
||||
function errorInfo(error: unknown): ToolErrorInfo | undefined {
|
||||
try {
|
||||
@@ -583,7 +681,7 @@ export class ToolRegistry extends Service {
|
||||
// Regenerate from the calling scope's visible tools in stable order.
|
||||
text: (context) => {
|
||||
this.requireCodeRuntime()
|
||||
return renderToolsSdk(this.schemas(context.scope).filter(schema => schema.name !== RUN_CODE_NAME))
|
||||
return renderToolsSdk(this.sdkSchemas(context.scope))
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -636,6 +734,13 @@ export class ToolRegistry extends Service {
|
||||
*/
|
||||
register(definition: ToolDefinition): () => void {
|
||||
const name = definition.name
|
||||
const output = (definition as Partial<ToolDefinition>).output
|
||||
if (output === undefined || typeof output !== 'object'
|
||||
|| typeof output.render !== 'function'
|
||||
|| (output.presentationMeta !== undefined && typeof output.presentationMeta !== 'function')) {
|
||||
throw new TypeError(`tool "${name}" must declare output { schema, render, presentationMeta? }`)
|
||||
}
|
||||
assertSupportedJsonSchema(output.schema)
|
||||
const timeoutMs = definition.timeoutMs
|
||||
if (timeoutMs !== undefined
|
||||
&& (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
|
||||
@@ -769,13 +874,34 @@ export class ToolRegistry extends Service {
|
||||
return [...this.view(scope).visible.values()].map(definition => this.schemaOf(definition, true))
|
||||
}
|
||||
|
||||
/** Project visible callable tools onto the generated Code Mode SDK contract. */
|
||||
private sdkSchemas(scope?: ScopeKey): ToolSdkSchema[] {
|
||||
return [...this.view(scope).visible.values()]
|
||||
.filter(definition => definition.name !== RUN_CODE_NAME)
|
||||
.map((definition): ToolSdkSchema => {
|
||||
const output = snapshotJsonValue(definition.output.schema)
|
||||
/* v8 ignore next -- registration already validated and retained this schema as lossless JSON. */
|
||||
if (output === undefined) {
|
||||
throw new Error(`tool "${definition.name}" output schema must be lossless JSON before SDK projection`)
|
||||
}
|
||||
return {
|
||||
...this.schemaOf(definition, true),
|
||||
output,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Project one definition onto the model-facing schema fields. */
|
||||
private schemaOf(definition: ToolDefinition, detachParameters: boolean): ToolSchema {
|
||||
const { name, description, parameters } = definition
|
||||
const detached = detachParameters ? snapshotJsonValue(parameters) : parameters
|
||||
if (detached === undefined) {
|
||||
throw new Error(`tool "${name}" parameters must be lossless JSON before schema projection`)
|
||||
}
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
parameters: detachParameters ? structuredClone(parameters) : parameters,
|
||||
parameters: detached,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -914,10 +1040,11 @@ export class ToolRegistry extends Service {
|
||||
return await next({
|
||||
kind: 'post-result',
|
||||
exec,
|
||||
result: {
|
||||
result: this.materializeFinalResult({
|
||||
content: [{ type: 'text', text: `Error: ${denialReason}` }],
|
||||
isError: true,
|
||||
},
|
||||
error: { message: denialReason },
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (this.callerCancelled(exec)) {
|
||||
@@ -970,13 +1097,7 @@ export class ToolRegistry extends Service {
|
||||
if (!tool) throw new ToolNotFoundError(exec.name)
|
||||
state.bodyInvoked = true
|
||||
const returned = await tool.execute(exec.arguments, exec)
|
||||
const content = Array.isArray(returned) ? returned : returned.content
|
||||
const meta = Array.isArray(returned) ? undefined : returned.meta
|
||||
const result: ToolExecutionResult = {
|
||||
content,
|
||||
isError: false,
|
||||
...meta !== undefined ? { meta } : {},
|
||||
}
|
||||
const result = this.createSuccessResult(exec, tool, returned)
|
||||
return isAborted(signal)
|
||||
? toolAbortedResult(result)
|
||||
: result
|
||||
@@ -1003,18 +1124,19 @@ export class ToolRegistry extends Service {
|
||||
carrier, 'tools/execute', mutableExec,
|
||||
() => this.dispatchToolBody(mutableExec),
|
||||
)
|
||||
const normalized = this.normalizeDispatchResult(exec, result)
|
||||
const deferredContexts = this.deferredContexts.get(exec)
|
||||
/* v8 ignore next -- dispatch only receives executions minted by this registry's prepare stage */
|
||||
if (deferredContexts === undefined) throw new Error('tool registry scheduler invariant violated: unprepared execution')
|
||||
const resultWithDeferredContexts: ToolExecutionResult = deferredContexts.length === 0
|
||||
? result
|
||||
: {
|
||||
...result,
|
||||
? normalized
|
||||
: this.markCanonical(exec, {
|
||||
...normalized,
|
||||
additionalContexts: [
|
||||
...deferredContexts,
|
||||
...result.additionalContexts ?? [],
|
||||
...normalized.additionalContexts ?? [],
|
||||
],
|
||||
}
|
||||
})
|
||||
return {
|
||||
kind: 'post-result',
|
||||
result: this.callerCancelled(exec) && !resultWithDeferredContexts.isError
|
||||
@@ -1049,23 +1171,23 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply definition-owned content finalization, then materialize and notify a
|
||||
* final result that must bypass post-execute.
|
||||
* Materialize the candidate, apply definition-owned content finalization,
|
||||
* then materialize and notify the authoritative result.
|
||||
* @param exec - the prepared execution.
|
||||
* @param result - final result.
|
||||
* @returns the materialized final result.
|
||||
* @internal
|
||||
*/
|
||||
private finishScheduledExecution(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult {
|
||||
let snapshottedResult: ToolExecutionResult
|
||||
let materializedResult: ToolExecutionResult
|
||||
try {
|
||||
snapshottedResult = this.snapshotFinalResult(result)
|
||||
materializedResult = this.materializeFinalResult(result)
|
||||
} catch (error: unknown) {
|
||||
snapshottedResult = toolErrorResult(error)
|
||||
materializedResult = this.materializeFinalResult(toolErrorResult(error))
|
||||
}
|
||||
let finalResult: ToolExecutionResult
|
||||
try {
|
||||
finalResult = this.materializeFinalResult(this.applyFinalContent(exec, snapshottedResult))
|
||||
finalResult = this.materializeFinalResult(this.applyFinalContent(exec, materializedResult))
|
||||
} catch (error: unknown) {
|
||||
finalResult = this.materializeFinalResult(toolErrorResult(error))
|
||||
}
|
||||
@@ -1174,37 +1296,113 @@ export class ToolRegistry extends Service {
|
||||
)
|
||||
const decisionContexts = decision.additionalContexts ?? []
|
||||
if (decision.kind === 'block') {
|
||||
return {
|
||||
const message = failureMessageFromContent(decision.feedback)
|
||||
return this.markCanonical(exec, {
|
||||
content: decision.feedback,
|
||||
isError: true,
|
||||
error: { message },
|
||||
...decisionContexts.length > 0 ? { additionalContexts: decisionContexts } : {},
|
||||
}
|
||||
})
|
||||
}
|
||||
if (Object.hasOwn(decision, 'content') && Object.hasOwn(decision, 'value')) {
|
||||
throw new TypeError('tools/post-execute accept decision cannot replace both value and content')
|
||||
}
|
||||
// Accept: replace content if supplied, preserve the dispatched outcome, and
|
||||
// append decision contexts after contexts deferred by the tool body.
|
||||
const additionalContexts = [
|
||||
...result.additionalContexts ?? [],
|
||||
...decisionContexts,
|
||||
]
|
||||
return {
|
||||
...result,
|
||||
...decision.content ? { content: decision.content } : {},
|
||||
...additionalContexts.length > 0 ? { additionalContexts } : {},
|
||||
if (Object.hasOwn(decision, 'value')) {
|
||||
if (result.isError) {
|
||||
throw new TypeError('tools/post-execute cannot replace the value of a failed result')
|
||||
}
|
||||
const tool = this.get(exec.name, exec.agent)
|
||||
if (tool === undefined) throw new ToolNotFoundError(exec.name)
|
||||
const replaced = this.createSuccessResult(exec, tool, decision.value)
|
||||
return this.markCanonical(exec, {
|
||||
...replaced,
|
||||
...additionalContexts.length > 0 ? { additionalContexts } : {},
|
||||
})
|
||||
}
|
||||
return this.markCanonical(exec, {
|
||||
...result,
|
||||
...decision.content !== undefined ? { content: decision.content } : {},
|
||||
...additionalContexts.length > 0 ? { additionalContexts } : {},
|
||||
})
|
||||
}
|
||||
|
||||
/** Validate and detach one candidate outcome before tool-owned final content. */
|
||||
private snapshotFinalResult(result: ToolExecutionResult): ToolExecutionResult {
|
||||
const detached = snapshotJsonValue(result)
|
||||
if (detached === undefined) {
|
||||
throw new TypeError('tool result must be losslessly JSON-serializable')
|
||||
/** Registry-normalized results and the exact dispatch that validated each value. */
|
||||
private readonly canonicalResults = new WeakMap<object, ToolExecutionToken>()
|
||||
|
||||
/** Mark one registry-normalized result as canonical only for its owning dispatch. */
|
||||
private markCanonical<T extends ToolExecutionResult>(exec: ToolExecution, result: T): T {
|
||||
this.canonicalResults.set(result, exec.token)
|
||||
return result
|
||||
}
|
||||
|
||||
/** Snapshot, validate, render, and optionally project one successful body value. */
|
||||
private createSuccessResult(exec: ToolExecution, tool: ToolDefinition, candidate: unknown): ToolExecutionSuccess {
|
||||
const detached = snapshotToolValue(tool.name, candidate)
|
||||
const violations = validateJsonSchemaValue(tool.output.schema, detached, 'value')
|
||||
if (violations.length > 0) throw new ToolOutputError(tool.name, violations)
|
||||
const value = deepFreeze(detached)
|
||||
let rendered: ContentBlock[]
|
||||
try {
|
||||
rendered = tool.output.render(exec.arguments, value)
|
||||
} catch (error: unknown) {
|
||||
throw projectionError(tool.name, 'render', error)
|
||||
}
|
||||
return detached
|
||||
const content = snapshotProjection(tool.name, 'render', rendered)
|
||||
let meta: JsonValue | undefined
|
||||
if (exec.parent === undefined && tool.output.presentationMeta !== undefined) {
|
||||
let projected: JsonValue
|
||||
try {
|
||||
projected = tool.output.presentationMeta(exec.arguments, value)
|
||||
} catch (error: unknown) {
|
||||
throw projectionError(tool.name, 'presentationMeta', error)
|
||||
}
|
||||
meta = snapshotProjection(tool.name, 'presentationMeta', projected)
|
||||
}
|
||||
return this.markCanonical(exec, this.materializeFinalResult({
|
||||
isError: false,
|
||||
value,
|
||||
content,
|
||||
...meta !== undefined ? { meta } : {},
|
||||
}) as ToolExecutionSuccess)
|
||||
}
|
||||
|
||||
/** Normalize an around-dispatch wrapper's authored result through the owning output contract. */
|
||||
private normalizeDispatchResult(exec: ToolExecution, result: ToolExecutionResult): ToolExecutionResult {
|
||||
if (this.canonicalResults.get(result) === exec.token) return result
|
||||
if (result.isError) {
|
||||
return this.markCanonical(exec, {
|
||||
isError: true,
|
||||
error: result.error,
|
||||
content: result.content,
|
||||
...result.meta !== undefined ? { meta: result.meta } : {},
|
||||
...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {},
|
||||
})
|
||||
}
|
||||
const tool = this.get(exec.name, exec.agent)
|
||||
if (tool === undefined) throw new ToolNotFoundError(exec.name)
|
||||
const normalized = this.createSuccessResult(exec, tool, result.value)
|
||||
return this.markCanonical(exec, {
|
||||
...normalized,
|
||||
...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {},
|
||||
})
|
||||
}
|
||||
|
||||
/** Materialize the authoritative commit outcome once, immediately before `tools/result`. */
|
||||
private materializeFinalResult(result: ToolExecutionResult): ToolExecutionResult {
|
||||
return deepFreeze(this.snapshotFinalResult(result))
|
||||
const presentation = {
|
||||
content: result.content,
|
||||
...result.meta !== undefined ? { meta: result.meta } : {},
|
||||
...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {},
|
||||
}
|
||||
if (result.isError) {
|
||||
return materializePresentation({ isError: true as const, error: result.error, ...presentation })
|
||||
}
|
||||
const detached = materializePresentation({ isError: false as const, ...presentation })
|
||||
return deepFreeze({ ...detached, value: result.value })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1215,10 +1413,11 @@ function createExecutionToken(): ToolExecutionToken {
|
||||
|
||||
function toolErrorResult(error: unknown): ToolExecutionResult {
|
||||
const info = errorInfo(error)
|
||||
const message = errorMessage(error)
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: ${errorMessage(error)}` }],
|
||||
content: [{ type: 'text', text: `Error: ${message}` }],
|
||||
isError: true,
|
||||
...info ? { error: info } : {},
|
||||
error: { message, ...info ? { info } : {} },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1266,7 +1465,10 @@ function toolAbortedResult(prior?: ToolExecutionResult): ToolExecutionResult {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Error: tool call aborted' }],
|
||||
isError: true,
|
||||
error: { name: 'AbortError', code: TOOL_ABORTED },
|
||||
error: {
|
||||
message: 'tool call aborted',
|
||||
info: { name: 'AbortError', code: TOOL_ABORTED },
|
||||
},
|
||||
...additionalContexts.length > 0 ? { additionalContexts } : {},
|
||||
}
|
||||
}
|
||||
@@ -1277,7 +1479,10 @@ function toolAbortedBeforeDispatchResult(prior?: ToolExecutionResult): ToolExecu
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
|
||||
isError: true,
|
||||
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
|
||||
error: {
|
||||
message: 'tool call aborted before dispatch',
|
||||
info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
|
||||
},
|
||||
...additionalContexts.length > 0 ? { additionalContexts } : {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,323 +1,656 @@
|
||||
/**
|
||||
* Structured-output JSON Schema subset for subagents and workflows. It supports
|
||||
* one scalar `type`; object `properties`/`required`/boolean
|
||||
* `additionalProperties`; array `items`; scalar `enum`/`const`; and JSON-valued
|
||||
* annotations. Unsupported or misplaced keywords reject rather than being
|
||||
* accepted without enforcement, and structured-output roots must be objects.
|
||||
* Enforced JSON Schema subset shared by tool outputs, generated Code Mode
|
||||
* types, subagents, and workflows. The subset accepts any JSON root, an
|
||||
* annotation-only schema for unconstrained JSON, one scalar `type`, object
|
||||
* `properties`/`required`/boolean `additionalProperties`, array `items`,
|
||||
* type-correct scalar `enum`/`const`, and exact-one `oneOf`.
|
||||
*
|
||||
* Unsupported or misplaced keywords reject rather than being accepted without
|
||||
* enforcement. Consumers that require an object root apply
|
||||
* {@link assertObjectJsonSchema} at their own boundary.
|
||||
* @module dsh-tools/json-schema
|
||||
*/
|
||||
|
||||
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import { isJsonValue, type JsonValue } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** The scalar values `enum`/`const` may carry (finite numbers only). */
|
||||
export type StructuredScalar = string | number | boolean | null
|
||||
/** Scalar JSON values supported by `enum` and `const`. */
|
||||
export type JsonSchemaScalar = string | number | boolean | null
|
||||
|
||||
/** The `type` keywords the subset accepts. */
|
||||
export type StructuredSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null'
|
||||
/** Single-type keywords accepted by the enforced subset. */
|
||||
export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null'
|
||||
|
||||
/** Scalar-only schema types accepted by literal constraints. */
|
||||
type JsonSchemaScalarType = Exclude<JsonSchemaType, 'object' | 'array'>
|
||||
|
||||
/**
|
||||
* One node of the structured-output schema subset. Recursive via `properties`
|
||||
* and `items`; see the module doc for the exact keyword semantics.
|
||||
* One raw JSON Schema node in the enforced subset. The optional fields express
|
||||
* the external wire shape; {@link assertSupportedJsonSchema} rejects invalid
|
||||
* combinations before a caller treats the node as trusted.
|
||||
*/
|
||||
export interface StructuredSchemaNode {
|
||||
type: StructuredSchemaType
|
||||
export interface JsonSchemaNode {
|
||||
/** Omit with no constraints for any JSON value, or use `oneOf`. */
|
||||
type?: JsonSchemaType
|
||||
/** Exactly one branch must validate; at least two branches are required. */
|
||||
oneOf?: JsonSchemaNode[]
|
||||
/** Nested property schemas (`type: 'object'` only). */
|
||||
properties?: Record<string, StructuredSchemaNode>
|
||||
properties?: Record<string, JsonSchemaNode>
|
||||
/** Required property names; each must appear in `properties`. */
|
||||
required?: string[]
|
||||
/** `false` rejects undeclared keys; absent/`true` allows them (JSON Schema default). */
|
||||
/** `false` rejects undeclared keys; absent/`true` follows JSON Schema's open 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
|
||||
/** Item schema (`type: 'array'` only); absent accepts any JSON item. */
|
||||
items?: JsonSchemaNode
|
||||
/** Allowed values for a scalar node. */
|
||||
enum?: JsonSchemaScalar[]
|
||||
/** The single allowed value for a scalar node. */
|
||||
const?: JsonSchemaScalar
|
||||
/** 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
|
||||
/** Annotation, ignored for validation but required to be lossless JSON. */
|
||||
default?: JsonValue
|
||||
/** Annotation, ignored for validation but required to be lossless JSON. */
|
||||
examples?: JsonValue
|
||||
}
|
||||
|
||||
/** A structured-output schema: an OBJECT-rooted {@link StructuredSchemaNode}. */
|
||||
export type StructuredOutputSchema = StructuredSchemaNode & { type: 'object' }
|
||||
/** A consumer-constrained object-rooted schema. */
|
||||
export type ObjectJsonSchema = JsonSchemaNode & { 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.
|
||||
* Thrown when a raw schema falls outside the enforced subset. `violations`
|
||||
* lists every offending path instead of stopping at the first author error.
|
||||
*/
|
||||
export class OutputSchemaError extends HarnessError {
|
||||
/** The individual violation messages, in walk order. */
|
||||
export class JsonSchemaError extends HarnessError {
|
||||
/** Individual schema violations in walk order. */
|
||||
readonly violations: string[]
|
||||
|
||||
constructor(violations: string[]) {
|
||||
super(`unsupported output schema: ${violations.join('; ')}`, 'UNSUPPORTED_SCHEMA')
|
||||
this.name = 'OutputSchemaError'
|
||||
super(`unsupported JSON schema: ${violations.join('; ')}`, 'UNSUPPORTED_SCHEMA')
|
||||
this.name = 'JsonSchemaError'
|
||||
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 CONSTRAINT_KEYWORDS = new Set([
|
||||
'type',
|
||||
'oneOf',
|
||||
'properties',
|
||||
'required',
|
||||
'additionalProperties',
|
||||
'items',
|
||||
'enum',
|
||||
'const',
|
||||
])
|
||||
const ANNOTATION_KEYWORDS = new Set(['description', 'title', 'default', 'examples'])
|
||||
const SCHEMA_TYPES: readonly JsonSchemaType[] = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null']
|
||||
|
||||
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)
|
||||
/* jscpd:ignore-start -- this realm boundary mirrors the session-owned lossless-JSON intrinsic test */
|
||||
/** Whether a realm-owned intrinsic prototype is backed by its native constructor. */
|
||||
function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): boolean {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor')
|
||||
const constructor: unknown = descriptor?.value
|
||||
if (typeof constructor !== 'function') return false
|
||||
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)
|
||||
return constructor.name === name
|
||||
&& constructor.prototype === prototype
|
||||
&& Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }`
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 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)
|
||||
/** Whether a candidate is one realm's intrinsic `Object.prototype`. */
|
||||
function isIntrinsicObjectPrototype(value: object): boolean {
|
||||
return Object.getPrototypeOf(value) === null && hasIntrinsicConstructor(value, 'Object')
|
||||
}
|
||||
|
||||
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`)
|
||||
/**
|
||||
* Test for a realm-agnostic plain JSON record without accepting arrays or
|
||||
* exotic objects.
|
||||
* @param value - candidate record from any JavaScript realm.
|
||||
* @returns Whether the value has a plain-object prototype chain.
|
||||
*/
|
||||
export function isPlainJsonRecord(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
||||
try {
|
||||
const prototype: unknown = Object.getPrototypeOf(value)
|
||||
return prototype === null
|
||||
|| typeof prototype === 'object' && isIntrinsicObjectPrototype(prototype)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether an array uses one realm's intrinsic `Array.prototype`. */
|
||||
function hasPlainArrayPrototype(value: unknown[]): boolean {
|
||||
const prototype: unknown = Object.getPrototypeOf(value)
|
||||
if (!Array.isArray(prototype) || !hasIntrinsicConstructor(prototype, 'Array')) return false
|
||||
const objectPrototype: unknown = Object.getPrototypeOf(prototype)
|
||||
return typeof objectPrototype === 'object'
|
||||
&& objectPrototype !== null
|
||||
&& isIntrinsicObjectPrototype(objectPrototype)
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/** Return whether a record contains only own enumerable string keys. */
|
||||
function hasOnlyEnumerableStringKeys(value: object): boolean {
|
||||
try {
|
||||
return Reflect.ownKeys(value)
|
||||
.every(key => typeof key === 'string' && Object.prototype.propertyIsEnumerable.call(value, key))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test for an ordinary schema record whose keys survive JSON projection.
|
||||
* @param value - candidate record from any JavaScript realm.
|
||||
* @returns Whether the record has an intrinsic prototype and only own enumerable string keys.
|
||||
*/
|
||||
export function isJsonSchemaRecord(value: unknown): value is Record<string, unknown> {
|
||||
return isPlainJsonRecord(value) && hasOnlyEnumerableStringKeys(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test for a dense ordinary array with no JSON-invisible decorations.
|
||||
* @param value - candidate array from any JavaScript realm.
|
||||
* @returns Whether the array is intrinsic, dense, and undecorated.
|
||||
*/
|
||||
export function isPlainJsonArray(value: unknown): value is unknown[] {
|
||||
if (!Array.isArray(value)) return false
|
||||
try {
|
||||
if (!hasPlainArrayPrototype(value) || Reflect.ownKeys(value).length !== value.length + 1) return false
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
if (!Object.hasOwn(value, index)) return false
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Lossless finite JSON number, excluding negative zero. */
|
||||
function isJsonNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0)
|
||||
}
|
||||
|
||||
/** Whether a scalar is valid for one declared schema type. */
|
||||
function scalarMatches(type: JsonSchemaScalarType, value: unknown): value is JsonSchemaScalar {
|
||||
switch (type) {
|
||||
case 'string': return typeof value === 'string'
|
||||
case 'number': return isJsonNumber(value)
|
||||
case 'integer': return isJsonNumber(value) && Number.isInteger(value)
|
||||
case 'boolean': return typeof value === 'boolean'
|
||||
case 'null': return value === null
|
||||
/* v8 ignore next -- JsonSchemaScalarType is closed; this retains compile-time exhaustiveness. */
|
||||
default: return assertNever(type, 'JsonSchemaType')
|
||||
}
|
||||
}
|
||||
|
||||
/** Deferred work for the stack-safe raw-schema walk. */
|
||||
type SchemaWalkTask =
|
||||
| { kind: 'enter'; node: unknown; path: string }
|
||||
| { kind: 'leave'; node: object }
|
||||
| { kind: 'one-of-tail'; node: Record<string, unknown>; path: string }
|
||||
| { kind: 'object-tail'; node: Record<string, unknown>; path: string; properties: unknown }
|
||||
|
||||
/** Keywords that are invalid beside `oneOf`. */
|
||||
const ONE_OF_SIBLING_KEYWORDS = ['properties', 'required', 'additionalProperties', 'items', 'enum', 'const'] as const
|
||||
|
||||
/** Validate object-only fields after its property schemas have been visited. */
|
||||
function checkObjectSchemaTail(
|
||||
node: Record<string, unknown>,
|
||||
path: string,
|
||||
properties: unknown,
|
||||
violations: string[],
|
||||
): void {
|
||||
const hasRequired = Object.hasOwn(node, 'required')
|
||||
const required = hasRequired ? node.required : undefined
|
||||
if (hasRequired) {
|
||||
if (!isPlainJsonArray(required) || required.some(entry => typeof entry !== 'string')) {
|
||||
violations.push(`${path}.required must be an array of strings`)
|
||||
} else {
|
||||
const declared = isJsonSchemaRecord(properties) ? properties : {}
|
||||
for (const key of required as string[]) {
|
||||
if (!Object.hasOwn(declared, key)) violations.push(`${path}.required names "${key}" which is not in properties`)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.hasOwn(node, 'additionalProperties') && typeof node.additionalProperties !== 'boolean') {
|
||||
violations.push(`${path}.additionalProperties must be a boolean`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect every violation for one raw schema tree without using the JavaScript call stack. */
|
||||
function checkSchemaNode(root: unknown, rootPath: string, violations: string[], seen: Set<object>): void {
|
||||
const tasks: SchemaWalkTask[] = [{ kind: 'enter', node: root, path: rootPath }]
|
||||
for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
|
||||
if (task.kind === 'leave') {
|
||||
seen.delete(task.node)
|
||||
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}"`)
|
||||
if (task.kind === 'one-of-tail') {
|
||||
for (const key of ONE_OF_SIBLING_KEYWORDS) {
|
||||
if (Object.hasOwn(task.node, key)) violations.push(`${task.path}.${key} is not supported beside oneOf`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (task.kind === 'object-tail') {
|
||||
checkObjectSchemaTail(task.node, task.path, task.properties, violations)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
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 { node, path } = task
|
||||
if (!isJsonSchemaRecord(node)) {
|
||||
violations.push(`${path} must be a schema object`)
|
||||
continue
|
||||
}
|
||||
if (seen.has(node)) {
|
||||
violations.push(`${path} is circular`)
|
||||
continue
|
||||
}
|
||||
seen.add(node)
|
||||
tasks.push({ kind: 'leave', node })
|
||||
|
||||
for (const key of Object.keys(node)) {
|
||||
if (CONSTRAINT_KEYWORDS.has(key)) continue
|
||||
if (ANNOTATION_KEYWORDS.has(key)) {
|
||||
try {
|
||||
if (!isJsonValue(node[key])) violations.push(`${path}.${key} annotation must be lossless JSON data`)
|
||||
} catch {
|
||||
violations.push(`${path}.${key} annotation must be lossless JSON data`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
violations.push(`${path}.${key} is not a supported keyword (subset: type/oneOf/properties/required/additionalProperties/items/enum/const + annotations)`)
|
||||
}
|
||||
if (Object.hasOwn(node, 'description') && typeof node.description !== 'string') {
|
||||
violations.push(`${path}.description must be a string`)
|
||||
}
|
||||
if (Object.hasOwn(node, 'title') && typeof node.title !== 'string') {
|
||||
violations.push(`${path}.title must be a string`)
|
||||
}
|
||||
|
||||
const hasType = Object.hasOwn(node, 'type')
|
||||
const hasOneOf = Object.hasOwn(node, 'oneOf')
|
||||
if (hasType && hasOneOf) {
|
||||
violations.push(`${path} cannot declare both type and oneOf`)
|
||||
continue
|
||||
}
|
||||
if (!hasType && !hasOneOf) {
|
||||
for (const key of ONE_OF_SIBLING_KEYWORDS) {
|
||||
if (Object.hasOwn(node, key)) violations.push(`${path}.${key} requires type or oneOf`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (hasOneOf) {
|
||||
const oneOf = node.oneOf
|
||||
tasks.push({ kind: 'one-of-tail', node, path })
|
||||
if (!isPlainJsonArray(oneOf) || oneOf.length < 2) {
|
||||
violations.push(`${path}.oneOf must be an array of at least two schemas`)
|
||||
} else {
|
||||
for (let index = oneOf.length - 1; index >= 0; index--) {
|
||||
tasks.push({ kind: 'enter', node: oneOf[index], path: `${path}.oneOf[${index}]` })
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
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('/')}`)
|
||||
continue
|
||||
}
|
||||
const schemaType = type as JsonSchemaType
|
||||
const allowedFor: Record<string, JsonSchemaType[]> = {
|
||||
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 (Object.hasOwn(node, key) && !types.includes(schemaType)) {
|
||||
violations.push(`${path}.${key} is not supported on type "${schemaType}"`)
|
||||
}
|
||||
}
|
||||
|
||||
switch (schemaType) {
|
||||
case 'object': {
|
||||
const properties = Object.hasOwn(node, 'properties') ? node.properties : undefined
|
||||
tasks.push({ kind: 'object-tail', node, path, properties })
|
||||
if (Object.hasOwn(node, 'properties')) {
|
||||
if (!isJsonSchemaRecord(properties)) {
|
||||
violations.push(`${path}.properties must be an object of schemas`)
|
||||
} else {
|
||||
const entries = Object.entries(properties)
|
||||
for (let index = entries.length - 1; index >= 0; index--) {
|
||||
const entry = entries[index]
|
||||
/* v8 ignore next -- the loop is bounded by the captured entry count. */
|
||||
if (entry === undefined) continue
|
||||
tasks.push({ kind: 'enter', node: entry[1], path: `${path}.properties.${entry[0]}` })
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
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`)
|
||||
case 'array': {
|
||||
if (Object.hasOwn(node, 'items')) tasks.push({ kind: 'enter', node: node.items, path: `${path}.items` })
|
||||
break
|
||||
}
|
||||
case 'string':
|
||||
case 'number':
|
||||
case 'integer':
|
||||
case 'boolean':
|
||||
case 'null': {
|
||||
const hasEnum = Object.hasOwn(node, 'enum')
|
||||
const allowed = hasEnum ? node.enum : undefined
|
||||
const enumValid = isPlainJsonArray(allowed)
|
||||
&& allowed.length > 0
|
||||
&& allowed.every(entry => scalarMatches(schemaType, entry))
|
||||
if (hasEnum && !enumValid) {
|
||||
violations.push(`${path}.enum must be a non-empty array of ${schemaType} values`)
|
||||
}
|
||||
const hasConst = Object.hasOwn(node, 'const')
|
||||
const declaredConst = hasConst ? node.const : undefined
|
||||
const constValid = scalarMatches(schemaType, declaredConst)
|
||||
if (hasConst) {
|
||||
if (!constValid) {
|
||||
violations.push(`${path}.const must be a ${schemaType} value`)
|
||||
} else if (enumValid && !allowed.includes(declaredConst)) {
|
||||
violations.push(`${path}.const must be one of ${path}.enum when both are declared`)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
if (node.additionalProperties !== undefined && typeof node.additionalProperties !== 'boolean') {
|
||||
violations.push(`${path}.additionalProperties must be a boolean`)
|
||||
}
|
||||
break
|
||||
/* v8 ignore next -- schemaType was narrowed from the closed SCHEMA_TYPES table above. */
|
||||
default: assertNever(schemaType, 'JsonSchemaType')
|
||||
}
|
||||
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.
|
||||
* Assert that an arbitrary raw schema uses only the enforced subset.
|
||||
* Annotation-only schemas are accepted as the standard unconstrained-JSON
|
||||
* form; callers that require an object root use {@link assertObjectJsonSchema}.
|
||||
* @param schema - untrusted raw JSON Schema.
|
||||
* @returns Assertion that the schema belongs to the supported subset.
|
||||
*/
|
||||
export function assertSupportedOutputSchema(schema: unknown): asserts schema is StructuredOutputSchema {
|
||||
export function assertSupportedJsonSchema(schema: unknown): asserts schema is JsonSchemaNode {
|
||||
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)
|
||||
if (violations.length > 0) throw new JsonSchemaError(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')
|
||||
/**
|
||||
* Assert the enforced subset plus the object-root constraint retained by
|
||||
* subagent and workflow structured outputs.
|
||||
* @param schema - untrusted caller-supplied schema.
|
||||
* @returns Assertion that the schema belongs to the supported subset and has an object root.
|
||||
*/
|
||||
export function assertObjectJsonSchema(schema: unknown): asserts schema is ObjectJsonSchema {
|
||||
const violations: string[] = []
|
||||
checkSchemaNode(schema, 'schema', violations, new Set())
|
||||
if (violations.length === 0
|
||||
&& (!isJsonSchemaRecord(schema) || !Object.hasOwn(schema, 'type') || schema.type !== 'object')) {
|
||||
violations.push('schema.type must be "object" (structured output is object-rooted)')
|
||||
}
|
||||
// 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 (violations.length > 0) throw new JsonSchemaError(violations)
|
||||
}
|
||||
|
||||
/** Safely test the lossless JSON boundary when a getter may throw. */
|
||||
function safelyIsJsonValue(value: unknown): boolean {
|
||||
try {
|
||||
return isJsonValue(value)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if ('const' in node && value !== node.const) {
|
||||
return [`"${path}" must be ${JSON.stringify(node.const)}`]
|
||||
}
|
||||
|
||||
/** Root-aware diagnostic path for the parameter validator's empty sentinel. */
|
||||
function diagnosticPath(path: string): string {
|
||||
return path === '' ? 'arguments' : path
|
||||
}
|
||||
|
||||
/** Append one object property without a leading dot at an implicit root. */
|
||||
function propertyPath(path: string, key: string): string {
|
||||
return path === '' ? key : `${path}.${key}`
|
||||
}
|
||||
|
||||
/** One child evaluation deferred by a container or exact-one union frame. */
|
||||
interface ValueChild {
|
||||
readonly node: JsonSchemaNode
|
||||
readonly value: unknown
|
||||
readonly path: string
|
||||
}
|
||||
|
||||
/** Explicit call frame for stack-safe schema-value validation. */
|
||||
interface ValueFrame {
|
||||
readonly node: JsonSchemaNode
|
||||
readonly value: unknown
|
||||
readonly path: string
|
||||
catches: boolean
|
||||
phase: 'start' | 'children'
|
||||
kind?: 'oneOf' | 'object' | 'array'
|
||||
children: ValueChild[]
|
||||
childIndex: number
|
||||
violations: string[]
|
||||
tailViolations: string[]
|
||||
matches: number
|
||||
}
|
||||
|
||||
/** The generic exception-containment diagnostic owned by one valid schema node. */
|
||||
function losslessValueViolation(path: string): string[] {
|
||||
return [`"${diagnosticPath(path)}" must be a lossless JSON value`]
|
||||
}
|
||||
|
||||
/** Append diagnostics without spreading a potentially wide child result as call arguments. */
|
||||
function appendViolations(target: string[], source: readonly string[]): void {
|
||||
for (const violation of source) target.push(violation)
|
||||
}
|
||||
|
||||
/** Initialize one validation frame with empty aggregation state. */
|
||||
function valueFrame(node: JsonSchemaNode, value: unknown, path: string): ValueFrame {
|
||||
return {
|
||||
node,
|
||||
value,
|
||||
path,
|
||||
catches: false,
|
||||
phase: 'start',
|
||||
children: [],
|
||||
childIndex: 0,
|
||||
violations: [],
|
||||
tailViolations: [],
|
||||
matches: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate one scalar node after its primitive type check. */
|
||||
function checkScalarValue(node: JsonSchemaNode, value: unknown, path: string): string[] {
|
||||
const allowed = Object.hasOwn(node, 'enum') ? node.enum : undefined
|
||||
if (allowed !== undefined && !allowed.includes(value as JsonSchemaScalar)) {
|
||||
return [`"${diagnosticPath(path)}" must be one of ${JSON.stringify(allowed)}`]
|
||||
}
|
||||
if (Object.hasOwn(node, 'const') && value !== node.const) {
|
||||
return [`"${diagnosticPath(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')
|
||||
/** Validate one trusted schema/value pair with explicit frames rather than recursive calls. */
|
||||
function checkValue(schema: JsonSchemaNode, value: unknown, path: string): string[] {
|
||||
const frames: ValueFrame[] = [valueFrame(schema, value, path)]
|
||||
let rootResult: string[] | undefined
|
||||
|
||||
const receive = (result: string[]): void => {
|
||||
const parent = frames.at(-1)
|
||||
if (parent === undefined) {
|
||||
rootResult = result
|
||||
return
|
||||
}
|
||||
if (parent.kind === 'oneOf') {
|
||||
if (result.length === 0) parent.matches++
|
||||
} else {
|
||||
appendViolations(parent.violations, result)
|
||||
}
|
||||
}
|
||||
const finish = (result: string[]): void => {
|
||||
frames.pop()
|
||||
receive(result)
|
||||
}
|
||||
|
||||
while (frames.length > 0) {
|
||||
const frame = frames.at(-1)
|
||||
/* v8 ignore next -- the loop condition guarantees a current frame. */
|
||||
if (frame === undefined) break
|
||||
try {
|
||||
if (frame.phase === 'children') {
|
||||
if (frame.childIndex < frame.children.length) {
|
||||
const child = frame.children[frame.childIndex]
|
||||
/* v8 ignore next -- childIndex is bounded by children.length. */
|
||||
if (child === undefined) throw new Error('missing schema-value child frame')
|
||||
frame.childIndex++
|
||||
frames.push(valueFrame(child.node, child.value, child.path))
|
||||
continue
|
||||
}
|
||||
if (frame.kind === 'oneOf') {
|
||||
finish(frame.matches === 1 ? [] : [`"${diagnosticPath(frame.path)}" must match exactly one oneOf branch (matched ${frame.matches})`])
|
||||
continue
|
||||
}
|
||||
appendViolations(frame.violations, frame.tailViolations)
|
||||
if (frame.violations.length > 0) {
|
||||
finish(frame.violations)
|
||||
} else if (frame.kind === 'object') {
|
||||
finish(safelyIsJsonValue(frame.value) ? [] : [`"${diagnosticPath(frame.path)}" must be a lossless JSON object`])
|
||||
} else {
|
||||
finish(safelyIsJsonValue(frame.value) ? [] : [`"${diagnosticPath(frame.path)}" must be a dense lossless JSON array`])
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const nodeType = Object.hasOwn(frame.node, 'type') ? frame.node.type : undefined
|
||||
frame.catches = !(nodeType !== undefined && !(SCHEMA_TYPES as readonly unknown[]).includes(nodeType))
|
||||
const oneOf = Object.hasOwn(frame.node, 'oneOf') ? frame.node.oneOf : undefined
|
||||
if (oneOf !== undefined) {
|
||||
frame.kind = 'oneOf'
|
||||
frame.children = Array.from(oneOf, branch => ({ node: branch, value: frame.value, path: frame.path }))
|
||||
frame.childIndex = 0
|
||||
frame.matches = 0
|
||||
frame.phase = 'children'
|
||||
continue
|
||||
}
|
||||
if (nodeType === undefined) {
|
||||
finish(safelyIsJsonValue(frame.value) ? [] : losslessValueViolation(frame.path))
|
||||
continue
|
||||
}
|
||||
|
||||
switch (nodeType) {
|
||||
case 'object': {
|
||||
if (!isPlainJsonRecord(frame.value)) {
|
||||
finish([`"${diagnosticPath(frame.path)}" must be an object`])
|
||||
break
|
||||
}
|
||||
const properties = Object.hasOwn(frame.node, 'properties') ? frame.node.properties ?? {} : {}
|
||||
const violations: string[] = []
|
||||
const required = Object.hasOwn(frame.node, 'required') ? frame.node.required ?? [] : []
|
||||
for (const key of required) {
|
||||
if (!Object.hasOwn(frame.value, key) || frame.value[key] === undefined) {
|
||||
violations.push(`missing required property "${propertyPath(frame.path, key)}"`)
|
||||
}
|
||||
}
|
||||
const children: ValueChild[] = []
|
||||
for (const [key, child] of Object.entries(properties)) {
|
||||
if (!Object.hasOwn(frame.value, key) || frame.value[key] === undefined) continue
|
||||
children.push({ node: child, value: frame.value[key], path: propertyPath(frame.path, key) })
|
||||
}
|
||||
const tailViolations: string[] = []
|
||||
if (Object.hasOwn(frame.node, 'additionalProperties') && frame.node.additionalProperties === false) {
|
||||
for (const key of Object.keys(frame.value)) {
|
||||
if (!Object.hasOwn(properties, key)) {
|
||||
tailViolations.push(`"${propertyPath(frame.path, key)}" is not a declared property (additionalProperties: false)`)
|
||||
}
|
||||
}
|
||||
}
|
||||
frame.kind = 'object'
|
||||
frame.children = children
|
||||
frame.childIndex = 0
|
||||
frame.violations = violations
|
||||
frame.tailViolations = tailViolations
|
||||
frame.phase = 'children'
|
||||
break
|
||||
}
|
||||
case 'array': {
|
||||
if (!Array.isArray(frame.value)) {
|
||||
finish([`"${diagnosticPath(frame.path)}" must be an array`])
|
||||
break
|
||||
}
|
||||
const items = Object.hasOwn(frame.node, 'items') ? frame.node.items : undefined
|
||||
const children = items === undefined
|
||||
? []
|
||||
: frame.value.flatMap((entry, index): ValueChild[] => [{ node: items, value: entry, path: `${frame.path}[${index}]` }])
|
||||
frame.kind = 'array'
|
||||
frame.children = children
|
||||
frame.childIndex = 0
|
||||
frame.violations = []
|
||||
frame.phase = 'children'
|
||||
break
|
||||
}
|
||||
case 'string':
|
||||
finish(typeof frame.value === 'string'
|
||||
? checkScalarValue(frame.node, frame.value, frame.path)
|
||||
: [`"${diagnosticPath(frame.path)}" must be a string`])
|
||||
break
|
||||
case 'number':
|
||||
finish(typeof frame.value !== 'number'
|
||||
? [`"${diagnosticPath(frame.path)}" must be a number`]
|
||||
: !isJsonNumber(frame.value)
|
||||
? [`"${diagnosticPath(frame.path)}" must be a finite JSON number`]
|
||||
: checkScalarValue(frame.node, frame.value, frame.path))
|
||||
break
|
||||
case 'integer':
|
||||
finish(!isJsonNumber(frame.value) || !Number.isInteger(frame.value)
|
||||
? [`"${diagnosticPath(frame.path)}" must be an integer`]
|
||||
: checkScalarValue(frame.node, frame.value, frame.path))
|
||||
break
|
||||
case 'boolean':
|
||||
finish(typeof frame.value === 'boolean'
|
||||
? checkScalarValue(frame.node, frame.value, frame.path)
|
||||
: [`"${diagnosticPath(frame.path)}" must be a boolean`])
|
||||
break
|
||||
case 'null':
|
||||
finish(frame.value === null
|
||||
? checkScalarValue(frame.node, frame.value, frame.path)
|
||||
: [`"${diagnosticPath(frame.path)}" must be null`])
|
||||
break
|
||||
default:
|
||||
finish(assertNever(nodeType, 'JsonSchemaType'))
|
||||
}
|
||||
} catch (error) {
|
||||
let failed = frames.pop()
|
||||
while (failed !== undefined && !failed.catches) failed = frames.pop()
|
||||
if (failed === undefined) throw error
|
||||
receive(losslessValueViolation(failed.path))
|
||||
}
|
||||
}
|
||||
|
||||
/* v8 ignore next -- every root frame finishes or throws. */
|
||||
return rootResult ?? losslessValueViolation(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a candidate value against an asserted raw schema. The function is
|
||||
* total for arbitrary values and returns path-qualified violations.
|
||||
* @param schema - a schema accepted by {@link assertSupportedJsonSchema}.
|
||||
* @param value - the candidate JSON value.
|
||||
* @param path - root label used in diagnostics.
|
||||
* @returns All violations in walk order; empty means valid.
|
||||
*/
|
||||
export function validateJsonSchemaValue(schema: JsonSchemaNode, value: unknown, path = 'value'): string[] {
|
||||
return checkValue(schema, value, path)
|
||||
}
|
||||
|
||||
@@ -1,181 +1,465 @@
|
||||
/** Typed tool-parameter DSL with argument inference and JSON Schema output. @module dsh-tools/schema */
|
||||
/** Unified JSON-value schema DSL, inference, compilation, and typed tool helper. @module dsh-tools/schema */
|
||||
|
||||
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
ToolDefinition,
|
||||
ToolExecuteReturn,
|
||||
ToolExecution,
|
||||
ToolExecutionResult,
|
||||
ToolRunContext,
|
||||
ToolResult,
|
||||
} from './index.ts'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolDefinition, ToolExecution, ToolExecutionResult, ToolRunContext, ToolResult } from './index.ts'
|
||||
import { assertSupportedJsonSchema, isJsonSchemaRecord, isPlainJsonArray, JsonSchemaError, validateJsonSchemaValue } from './json-schema.ts'
|
||||
import type { JsonSchemaNode, JsonSchemaScalar, ObjectJsonSchema } from './json-schema.ts'
|
||||
import type { ToolCallView, ToolResultView } from './presentation.ts'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SchemaSpec — the author-facing per-property type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Valid JSON Schema primitive types for tool parameters. */
|
||||
export type SchemaType = 'string' | 'number' | 'boolean' | 'object' | 'array'
|
||||
|
||||
/** One schema-spec property entry. */
|
||||
export interface SchemaProp {
|
||||
type: SchemaType
|
||||
/** Per-property required flag (NOT the JSON Schema top-level required array). */
|
||||
required?: true
|
||||
/** Human-readable description, surfaced in the JSON Schema as well. */
|
||||
/** Annotation keywords shared by every author-facing schema node. */
|
||||
export interface ValueSchemaAnnotations {
|
||||
/** Human-readable description projected into JSON Schema and generated types. */
|
||||
description?: string
|
||||
/** Enum of allowed values (strings only). */
|
||||
enum?: string[]
|
||||
/**
|
||||
* Model-visible JSON Schema default annotation. Validation does not apply it;
|
||||
* dynamic tool mounts may supply it even though first-party definitions do not.
|
||||
*/
|
||||
default?: unknown
|
||||
/** Nested properties for type: 'object'. */
|
||||
properties?: SchemaSpec
|
||||
/** Items schema for type: 'array'. */
|
||||
items?: SchemaProp
|
||||
/** Human-readable title projected into JSON Schema. */
|
||||
title?: string
|
||||
/** Non-validating default annotation; it must be lossless JSON data. */
|
||||
default?: JsonValue
|
||||
/** Non-validating examples annotation; it must be lossless JSON data. */
|
||||
examples?: JsonValue
|
||||
}
|
||||
|
||||
/** String value schema with type-correct literal constraints. */
|
||||
export interface StringValueSchemaSpec extends ValueSchemaAnnotations {
|
||||
type: 'string'
|
||||
enum?: readonly string[]
|
||||
const?: string
|
||||
}
|
||||
|
||||
/** Finite JSON-number schema with type-correct literal constraints. */
|
||||
export interface NumberValueSchemaSpec extends ValueSchemaAnnotations {
|
||||
type: 'number'
|
||||
enum?: readonly number[]
|
||||
const?: number
|
||||
}
|
||||
|
||||
/** Integer schema with type-correct literal constraints. */
|
||||
export interface IntegerValueSchemaSpec extends ValueSchemaAnnotations {
|
||||
type: 'integer'
|
||||
enum?: readonly number[]
|
||||
const?: number
|
||||
}
|
||||
|
||||
/** Boolean value schema with type-correct literal constraints. */
|
||||
export interface BooleanValueSchemaSpec extends ValueSchemaAnnotations {
|
||||
type: 'boolean'
|
||||
enum?: readonly boolean[]
|
||||
const?: boolean
|
||||
}
|
||||
|
||||
/** Null value schema with type-correct literal constraints. */
|
||||
export interface NullValueSchemaSpec extends ValueSchemaAnnotations {
|
||||
type: 'null'
|
||||
enum?: readonly null[]
|
||||
const?: null
|
||||
}
|
||||
|
||||
/** Array value schema; omitted `items` accepts any lossless JSON item. */
|
||||
export interface ArrayValueSchemaSpec extends ValueSchemaAnnotations {
|
||||
type: 'array'
|
||||
items?: ValueSchemaSpec
|
||||
}
|
||||
|
||||
/**
|
||||
* The author-facing parameter schema: a shallow map of property name to
|
||||
* {@link SchemaProp}. Required-ness is a per-property boolean (`required:
|
||||
* true`), not a separate array.
|
||||
* Explicit object value schema. Openness is mandatory so a nested or output
|
||||
* object never acquires an accidental JSON Schema default.
|
||||
*/
|
||||
export type SchemaSpec = Record<string, SchemaProp>
|
||||
export interface ObjectValueSchemaSpec extends ValueSchemaAnnotations {
|
||||
type: 'object'
|
||||
properties?: ParameterSchemaSpec
|
||||
additionalProperties: boolean
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// InferArgs — type-level mapping from SchemaSpec to TS argument type
|
||||
// ---------------------------------------------------------------------------
|
||||
/** Author-only unconstrained lossless JSON node. */
|
||||
export interface JsonValueSchemaSpec extends ValueSchemaAnnotations {
|
||||
type: 'json'
|
||||
}
|
||||
|
||||
/** Map a {@link SchemaType} to its TS primitive type. */
|
||||
type TypeOf<T extends SchemaType> =
|
||||
T extends 'string' ? string :
|
||||
T extends 'number' ? number :
|
||||
T extends 'boolean' ? boolean :
|
||||
T extends 'object' ? Record<string, unknown> :
|
||||
T extends 'array' ? unknown[] :
|
||||
never
|
||||
/** Exact-one union schema; at least two branches are required. */
|
||||
export interface OneOfValueSchemaSpec extends ValueSchemaAnnotations {
|
||||
oneOf: readonly [ValueSchemaSpec, ValueSchemaSpec, ...ValueSchemaSpec[]]
|
||||
}
|
||||
|
||||
/** One author-facing schema for any lossless JSON value root. */
|
||||
export type ValueSchemaSpec =
|
||||
| StringValueSchemaSpec
|
||||
| NumberValueSchemaSpec
|
||||
| IntegerValueSchemaSpec
|
||||
| BooleanValueSchemaSpec
|
||||
| NullValueSchemaSpec
|
||||
| ArrayValueSchemaSpec
|
||||
| ObjectValueSchemaSpec
|
||||
| JsonValueSchemaSpec
|
||||
| OneOfValueSchemaSpec
|
||||
|
||||
/** One implicit parameter-root property, optionally required. */
|
||||
export type ParameterPropertySpec = ValueSchemaSpec & { required?: true }
|
||||
|
||||
/**
|
||||
* Tool parameter schema. The map itself is an implicit open object root;
|
||||
* requiredness remains a per-property `required: true` annotation.
|
||||
*/
|
||||
export type ParameterSchemaSpec = {
|
||||
[key: string]: ParameterPropertySpec
|
||||
[key: symbol]: never
|
||||
}
|
||||
|
||||
/** Raw JSON Schema projection of the implicit parameter object. */
|
||||
export interface ParameterJsonSchema extends ObjectJsonSchema {
|
||||
properties: Record<string, JsonSchemaNode>
|
||||
}
|
||||
|
||||
/** Flatten an intersection into one object type for readable hovers. */
|
||||
type Simplify<T> = { [K in keyof T]: T[K] } & {}
|
||||
|
||||
/** Keys of `S` whose prop is marked `required: true`. */
|
||||
type RequiredKeys<S extends SchemaSpec> =
|
||||
{ [K in keyof S]: S[K] extends { required: true } ? K : never }[keyof S]
|
||||
/** String keys of one property map; runtime compilation rejects symbol keys. */
|
||||
type StringKeyOf<S> = Extract<keyof S, string>
|
||||
|
||||
/**
|
||||
* The VALUE type of one {@link SchemaProp} — optionality is handled at the
|
||||
* key level by {@link InferArgs}, never here.
|
||||
* - `properties` on 'object' → recurse into the nested SchemaSpec
|
||||
* - `items` on 'array' → recurse into the item prop (arrays of objects work)
|
||||
* - otherwise → the primitive for `type`
|
||||
*/
|
||||
type InferPropValue<P extends SchemaProp> =
|
||||
P extends { type: 'object'; properties: infer Sub extends SchemaSpec } ? InferArgs<Sub> :
|
||||
P extends { type: 'array'; items: infer Item extends SchemaProp } ? InferPropValue<Item>[] :
|
||||
TypeOf<P['type']>
|
||||
/** Keys of a property map marked `required: true`. */
|
||||
type RequiredKeys<S> = {
|
||||
[K in StringKeyOf<S>]: S[K] extends { required: true } ? K : never
|
||||
}[StringKeyOf<S>]
|
||||
|
||||
/**
|
||||
* Infer the TS argument type for a complete {@link SchemaSpec}.
|
||||
*
|
||||
* Properties marked `required: true` are required keys; all others are
|
||||
* genuinely optional keys (`?`), so callers may omit them entirely.
|
||||
*
|
||||
* Example:
|
||||
* ```ts
|
||||
* type Args = InferArgs<{ path: { type: 'string'; required: true }; limit: { type: 'number' } }>
|
||||
* // → { path: string; limit?: number }
|
||||
* ```
|
||||
*/
|
||||
export type InferArgs<S extends SchemaSpec> = Simplify<
|
||||
& { [K in RequiredKeys<S>]: InferPropValue<S[K]> }
|
||||
& { [K in Exclude<keyof S, RequiredKeys<S>>]?: InferPropValue<S[K]> }
|
||||
/** Infer the declared value of one parameter property without key optionality. */
|
||||
type InferProperty<P, Depth extends unknown[]> = InferValueAt<P, Depth>
|
||||
|
||||
/** Infer an implicit property map into required and optional object keys. */
|
||||
type InferProperties<S, Depth extends unknown[]> = Simplify<
|
||||
& { [K in RequiredKeys<S>]: InferProperty<S[K], Depth> }
|
||||
& { [K in Exclude<StringKeyOf<S>, RequiredKeys<S>>]?: InferProperty<S[K], Depth> }
|
||||
>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime conversion: SchemaSpec → JSON Schema
|
||||
// ---------------------------------------------------------------------------
|
||||
/** Infer an explicit object node, including its declared openness. */
|
||||
type InferObject<S extends { additionalProperties: boolean }, Depth extends unknown[]> =
|
||||
S extends { properties: infer P }
|
||||
? S['additionalProperties'] extends true
|
||||
? InferProperties<P, Depth> & Record<string, JsonValue>
|
||||
: InferProperties<P, Depth>
|
||||
: S['additionalProperties'] extends true
|
||||
? Record<string, JsonValue>
|
||||
: Record<string, never>
|
||||
|
||||
/** Infer a scalar node's literal constraint before its broad primitive type. */
|
||||
type InferScalar<S, Fallback> =
|
||||
S extends { const: infer C } ? C :
|
||||
S extends { enum: readonly (infer E)[] } ? E :
|
||||
Fallback
|
||||
|
||||
/** Add one schema-container level to bounded compile-time inference. */
|
||||
type NextInferenceDepth<Depth extends unknown[]> = [unknown, ...Depth]
|
||||
|
||||
/** Infer one node without recursively checking it against the full author union. */
|
||||
type InferValueAt<S, Depth extends unknown[]> =
|
||||
Depth['length'] extends 16 ? JsonValue :
|
||||
S extends { type: 'string' } ? InferScalar<S, string> :
|
||||
S extends { type: 'number' | 'integer' } ? InferScalar<S, number> :
|
||||
S extends { type: 'boolean' } ? InferScalar<S, boolean> :
|
||||
S extends { type: 'null' } ? null :
|
||||
S extends { type: 'array' }
|
||||
? S extends { items: infer I } ? InferValueAt<I, NextInferenceDepth<Depth>>[] : JsonValue[]
|
||||
: S extends { type: 'object'; additionalProperties: boolean }
|
||||
? InferObject<S, NextInferenceDepth<Depth>>
|
||||
: S extends { type: 'json' } ? JsonValue :
|
||||
S extends { oneOf: readonly unknown[] }
|
||||
? InferValueAt<S['oneOf'][number], NextInferenceDepth<Depth>>
|
||||
: never
|
||||
|
||||
/**
|
||||
* Convert a single {@link SchemaProp} to its JSON Schema `properties` entry.
|
||||
* The per-property `required` flag is collected; the caller builds the
|
||||
* top-level `required` array.
|
||||
* Infer the TypeScript value accepted by an author-facing value schema. Exact
|
||||
* inference is bounded to 16 container levels, then falls back to `JsonValue`.
|
||||
*/
|
||||
function propToJsonSchema(prop: SchemaProp): { schema: Record<string, unknown>; required: boolean } {
|
||||
const result: Record<string, unknown> = { type: prop.type }
|
||||
if (prop.description) result.description = prop.description
|
||||
if (prop.enum) result.enum = prop.enum
|
||||
if (prop.default !== undefined) result.default = prop.default
|
||||
export type InferValue<S> = InferValueAt<S, []>
|
||||
|
||||
const required = prop.required === true
|
||||
/** Infer the TypeScript argument object for an implicit parameter schema. */
|
||||
export type InferArgs<S> = InferProperties<S, []>
|
||||
|
||||
if (prop.type === 'object' && prop.properties) {
|
||||
const nested = schemaSpecToJsonSchema(prop.properties)
|
||||
result.properties = nested.properties
|
||||
if (nested.required && nested.required.length > 0) {
|
||||
result.required = nested.required
|
||||
}
|
||||
}
|
||||
const ANNOTATION_KEYS = ['description', 'title', 'default', 'examples'] as const
|
||||
|
||||
if (prop.type === 'array' && prop.items) {
|
||||
const { schema: itemsSchema } = propToJsonSchema(prop.items)
|
||||
result.items = itemsSchema
|
||||
}
|
||||
|
||||
return { schema: result, required }
|
||||
/** Throw one author-schema violation through the shared schema error type. */
|
||||
function authorError(message: string): never {
|
||||
throw new JsonSchemaError([message])
|
||||
}
|
||||
|
||||
/** The return type of {@link schemaSpecToJsonSchema}. */
|
||||
export interface JsonSchemaObject {
|
||||
type: 'object'
|
||||
properties: Record<string, unknown>
|
||||
/** Copy own annotation fields for validation by the raw-schema boundary. */
|
||||
function copyAnnotations(source: Record<string, unknown>, target: JsonSchemaNode): void {
|
||||
if (Object.hasOwn(source, 'description')) target.description = source.description as string
|
||||
if (Object.hasOwn(source, 'title')) target.title = source.title as string
|
||||
if (Object.hasOwn(source, 'default')) target.default = source.default as JsonValue
|
||||
if (Object.hasOwn(source, 'examples')) target.examples = source.examples as JsonValue
|
||||
}
|
||||
|
||||
/** Reject author-only keys outside one node's declared vocabulary. */
|
||||
function assertAuthorKeys(source: Record<string, unknown>, path: string, allowed: readonly string[]): void {
|
||||
for (const key of Object.keys(source)) {
|
||||
if (!allowed.includes(key)) authorError(`${path}.${key} is not supported by the value schema DSL`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Compiled form of one implicit property map. */
|
||||
interface CompiledPropertyMap {
|
||||
properties: Record<string, JsonSchemaNode>
|
||||
required?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a {@link SchemaSpec} to standard JSON Schema (`type: 'object'`,
|
||||
* `properties`, `required` array).
|
||||
*
|
||||
* This is a plain function — no schemastery or other framework dependency.
|
||||
* @param spec - the author-facing per-property schema to convert.
|
||||
* @returns the wire-format JSON Schema; the top-level `required` array is
|
||||
* omitted entirely when no property is marked required.
|
||||
*/
|
||||
export function schemaSpecToJsonSchema(spec: SchemaSpec): JsonSchemaObject {
|
||||
const properties: Record<string, unknown> = {}
|
||||
const required: string[] = []
|
||||
|
||||
for (const [key, prop] of Object.entries(spec)) {
|
||||
const { schema, required: isRequired } = propToJsonSchema(prop)
|
||||
properties[key] = schema
|
||||
if (isRequired) required.push(key)
|
||||
}
|
||||
|
||||
const result: JsonSchemaObject = {
|
||||
type: 'object',
|
||||
properties,
|
||||
}
|
||||
if (required.length > 0) result.required = required
|
||||
|
||||
return result
|
||||
/** Mutable holder used only while an iterative compilation root is unresolved. */
|
||||
interface CompileRoot<T> {
|
||||
value?: T
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime validation: model-generated args ↔ SchemaSpec
|
||||
// ---------------------------------------------------------------------------
|
||||
/** Where one compiled value node is installed. */
|
||||
type NodeDestination =
|
||||
| { kind: 'root'; holder: CompileRoot<JsonSchemaNode> }
|
||||
| { kind: 'property'; target: Record<string, JsonSchemaNode>; key: string }
|
||||
| { kind: 'item'; target: JsonSchemaNode }
|
||||
| { kind: 'one-of'; target: JsonSchemaNode[]; index: number }
|
||||
|
||||
/** Where one compiled property map is installed. */
|
||||
type PropertyMapDestination =
|
||||
| { kind: 'root'; holder: CompileRoot<CompiledPropertyMap> }
|
||||
| { kind: 'object'; target: JsonSchemaNode }
|
||||
|
||||
/** Deferred work for stack-safe author-schema compilation. */
|
||||
type CompileTask =
|
||||
| { kind: 'value'; input: unknown; path: string; allowRequired: boolean; destination: NodeDestination }
|
||||
| { kind: 'property-map'; input: unknown; path: string; destination: PropertyMapDestination }
|
||||
| {
|
||||
kind: 'property'
|
||||
property: unknown
|
||||
path: string
|
||||
key: string
|
||||
properties: Record<string, JsonSchemaNode>
|
||||
required: string[]
|
||||
}
|
||||
| {
|
||||
kind: 'property-map-tail'
|
||||
compiled: CompiledPropertyMap
|
||||
required: string[]
|
||||
destination: PropertyMapDestination
|
||||
}
|
||||
| { kind: 'leave'; input: object }
|
||||
|
||||
/** Install a compiled node without giving `__proto__` assignment semantics. */
|
||||
function assignCompiledNode(destination: NodeDestination, node: JsonSchemaNode): void {
|
||||
switch (destination.kind) {
|
||||
case 'root':
|
||||
destination.holder.value = node
|
||||
break
|
||||
case 'property':
|
||||
Object.defineProperty(destination.target, destination.key, {
|
||||
value: node,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
break
|
||||
case 'item':
|
||||
destination.target.items = node
|
||||
break
|
||||
case 'one-of':
|
||||
destination.target[destination.index] = node
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/** Install a compiled property map at its root or containing object node. */
|
||||
function assignCompiledPropertyMap(destination: PropertyMapDestination, compiled: CompiledPropertyMap): void {
|
||||
if (destination.kind === 'root') {
|
||||
destination.holder.value = compiled
|
||||
} else {
|
||||
destination.target.properties = compiled.properties
|
||||
}
|
||||
}
|
||||
|
||||
/** Execute an author-schema compilation task graph without recursive descent. */
|
||||
function runSchemaCompiler(initial: CompileTask): void {
|
||||
const seen = new Set<object>()
|
||||
const tasks: CompileTask[] = [initial]
|
||||
for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
|
||||
if (task.kind === 'leave') {
|
||||
seen.delete(task.input)
|
||||
continue
|
||||
}
|
||||
if (task.kind === 'property-map-tail') {
|
||||
if (task.required.length > 0) {
|
||||
task.compiled.required = task.required
|
||||
if (task.destination.kind === 'object') task.destination.target.required = task.required
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (task.kind === 'property') {
|
||||
if (!isJsonSchemaRecord(task.property)) authorError(`${task.path} must be a value schema object`)
|
||||
if (Object.hasOwn(task.property, 'required') && task.property.required !== true) {
|
||||
authorError(`${task.path}.required must be true when present`)
|
||||
}
|
||||
if (Object.hasOwn(task.property, 'required') && task.property.required === true) task.required.push(task.key)
|
||||
tasks.push({
|
||||
kind: 'value',
|
||||
input: task.property,
|
||||
path: task.path,
|
||||
allowRequired: true,
|
||||
destination: { kind: 'property', target: task.properties, key: task.key },
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (task.kind === 'property-map') {
|
||||
if (!isJsonSchemaRecord(task.input)) authorError(`${task.path} must be an object of value schemas`)
|
||||
if (seen.has(task.input)) authorError(`${task.path} is circular`)
|
||||
seen.add(task.input)
|
||||
const compiled: CompiledPropertyMap = { properties: {} }
|
||||
const required: string[] = []
|
||||
assignCompiledPropertyMap(task.destination, compiled)
|
||||
tasks.push({ kind: 'leave', input: task.input })
|
||||
tasks.push({ kind: 'property-map-tail', compiled, required, destination: task.destination })
|
||||
const entries = Object.entries(task.input)
|
||||
for (let index = entries.length - 1; index >= 0; index--) {
|
||||
const entry = entries[index]
|
||||
/* v8 ignore next -- the loop is bounded by the captured entry count. */
|
||||
if (entry === undefined) continue
|
||||
tasks.push({
|
||||
kind: 'property',
|
||||
property: entry[1],
|
||||
path: `${task.path}.${entry[0]}`,
|
||||
key: entry[0],
|
||||
properties: compiled.properties,
|
||||
required,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const { input, path } = task
|
||||
if (!isJsonSchemaRecord(input)) authorError(`${path} must be a value schema object`)
|
||||
if (seen.has(input)) authorError(`${path} is circular`)
|
||||
seen.add(input)
|
||||
const authorKeys = [...ANNOTATION_KEYS, ...(task.allowRequired ? ['required'] : [])]
|
||||
const node: JsonSchemaNode = {}
|
||||
assignCompiledNode(task.destination, node)
|
||||
tasks.push({ kind: 'leave', input })
|
||||
|
||||
if (Object.hasOwn(input, 'oneOf')) {
|
||||
assertAuthorKeys(input, path, [...authorKeys, 'oneOf', 'type'])
|
||||
if (Object.hasOwn(input, 'type')) authorError(`${path} cannot declare both type and oneOf`)
|
||||
if (!isPlainJsonArray(input.oneOf)) authorError(`${path}.oneOf must be an array of at least two value schemas`)
|
||||
const branches: JsonSchemaNode[] = []
|
||||
node.oneOf = branches
|
||||
copyAnnotations(input, node)
|
||||
for (let index = input.oneOf.length - 1; index >= 0; index--) {
|
||||
tasks.push({
|
||||
kind: 'value',
|
||||
input: input.oneOf[index],
|
||||
path: `${path}.oneOf[${index}]`,
|
||||
allowRequired: false,
|
||||
destination: { kind: 'one-of', target: branches, index },
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const inputType = Object.hasOwn(input, 'type') ? input.type : undefined
|
||||
switch (inputType) {
|
||||
case 'json':
|
||||
assertAuthorKeys(input, path, [...authorKeys, 'type'])
|
||||
copyAnnotations(input, node)
|
||||
break
|
||||
case 'object':
|
||||
assertAuthorKeys(input, path, [...authorKeys, 'type', 'properties', 'additionalProperties'])
|
||||
if (!Object.hasOwn(input, 'additionalProperties') || typeof input.additionalProperties !== 'boolean') {
|
||||
authorError(`${path}.additionalProperties must be explicitly true or false`)
|
||||
}
|
||||
node.type = 'object'
|
||||
copyAnnotations(input, node)
|
||||
node.additionalProperties = input.additionalProperties
|
||||
if (Object.hasOwn(input, 'properties')) {
|
||||
tasks.push({
|
||||
kind: 'property-map',
|
||||
input: input.properties,
|
||||
path: `${path}.properties`,
|
||||
destination: { kind: 'object', target: node },
|
||||
})
|
||||
}
|
||||
break
|
||||
case 'array':
|
||||
assertAuthorKeys(input, path, [...authorKeys, 'type', 'items'])
|
||||
node.type = 'array'
|
||||
copyAnnotations(input, node)
|
||||
if (Object.hasOwn(input, 'items')) {
|
||||
tasks.push({
|
||||
kind: 'value',
|
||||
input: input.items,
|
||||
path: `${path}.items`,
|
||||
allowRequired: false,
|
||||
destination: { kind: 'item', target: node },
|
||||
})
|
||||
}
|
||||
break
|
||||
case 'string':
|
||||
case 'number':
|
||||
case 'integer':
|
||||
case 'boolean':
|
||||
case 'null':
|
||||
assertAuthorKeys(input, path, [...authorKeys, 'type', 'enum', 'const'])
|
||||
node.type = inputType
|
||||
copyAnnotations(input, node)
|
||||
if (Object.hasOwn(input, 'enum')) {
|
||||
if (!isPlainJsonArray(input.enum)) authorError(`${path}.enum must be a non-empty array of scalar values`)
|
||||
node.enum = Array.from(input.enum, entry => entry as JsonSchemaScalar)
|
||||
}
|
||||
if (Object.hasOwn(input, 'const')) node.const = input.const as JsonSchemaScalar
|
||||
break
|
||||
default:
|
||||
authorError(`${path}.type must be string/number/integer/boolean/null/array/object/json, or use oneOf`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Compile one implicit property map, collecting per-property requiredness. */
|
||||
function compilePropertyMap(input: unknown, path: string): CompiledPropertyMap {
|
||||
const holder: CompileRoot<CompiledPropertyMap> = {}
|
||||
runSchemaCompiler({ kind: 'property-map', input, path, destination: { kind: 'root', holder } })
|
||||
/* v8 ignore next -- the root task assigns before scheduling any descendants. */
|
||||
return holder.value ?? authorError(`${path} did not compile`)
|
||||
}
|
||||
|
||||
/** Compile one author node without applying any consumer root restriction. */
|
||||
function compileValueSchema(input: unknown, path: string): JsonSchemaNode {
|
||||
const holder: CompileRoot<JsonSchemaNode> = {}
|
||||
runSchemaCompiler({ kind: 'value', input, path, allowRequired: false, destination: { kind: 'root', holder } })
|
||||
/* v8 ignore next -- the root task assigns before scheduling any descendants. */
|
||||
return holder.value ?? authorError(`${path} did not compile`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by a {@link defineTool} tool when the model-generated arguments don't
|
||||
* match the declared {@link SchemaSpec}. Extends {@link HarnessError}
|
||||
* (`code: 'INVALID_ARGS'`); the registry's execution pipeline catches it and
|
||||
* returns an `isError` ToolExecutionResult carrying the structured error, so
|
||||
* the model can self-correct and downstream plugins can route on the code.
|
||||
* Compile one author-facing value schema to the enforced raw JSON Schema
|
||||
* subset. The author-only `json` node becomes an annotation-only schema.
|
||||
* @param spec - schema for any JSON-value root.
|
||||
* @returns The asserted raw schema projection.
|
||||
*/
|
||||
export function valueSchemaSpecToJsonSchema(spec: ValueSchemaSpec): JsonSchemaNode {
|
||||
const schema = compileValueSchema(spec, 'schema')
|
||||
assertSupportedJsonSchema(schema)
|
||||
return schema
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile the implicit open parameter object into raw JSON Schema.
|
||||
* @param spec - per-property parameter definitions.
|
||||
* @returns An object-rooted raw schema with no implicit-root openness override.
|
||||
*/
|
||||
export function parameterSchemaSpecToJsonSchema(spec: ParameterSchemaSpec): ParameterJsonSchema {
|
||||
const compiled = compilePropertyMap(spec, 'parameters')
|
||||
const schema: ParameterJsonSchema = {
|
||||
type: 'object',
|
||||
properties: compiled.properties,
|
||||
...(compiled.required === undefined ? {} : { required: compiled.required }),
|
||||
}
|
||||
assertSupportedJsonSchema(schema)
|
||||
return schema
|
||||
}
|
||||
|
||||
/** Invalid model-generated arguments for a typed tool. */
|
||||
export class ToolArgsError extends HarnessError {
|
||||
/** The individual violation messages, in declaration order. */
|
||||
/** Individual violations in schema-walk order. */
|
||||
readonly violations: string[]
|
||||
|
||||
constructor(violations: string[]) {
|
||||
@@ -185,123 +469,48 @@ export class ToolArgsError extends HarnessError {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a value is a non-null, non-array object (a JSON Schema `object`). */
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/** Collect violations for one property value against its {@link SchemaProp}. */
|
||||
function checkValue(prop: SchemaProp, value: unknown, path: string): string[] {
|
||||
switch (prop.type) {
|
||||
case 'string': {
|
||||
if (typeof value !== 'string') return [`"${path}" must be a string`]
|
||||
break
|
||||
}
|
||||
case 'number': {
|
||||
if (typeof value !== 'number') return [`"${path}" must be a number`]
|
||||
break
|
||||
}
|
||||
case 'boolean': {
|
||||
if (typeof value !== 'boolean') return [`"${path}" must be a boolean`]
|
||||
break
|
||||
}
|
||||
case 'object': {
|
||||
if (!isPlainObject(value)) return [`"${path}" must be an object`]
|
||||
// Mirror the converter: an object without `properties` only type-checks.
|
||||
return prop.properties ? checkSpec(prop.properties, value, path) : []
|
||||
}
|
||||
case 'array': {
|
||||
if (!Array.isArray(value)) return [`"${path}" must be an array`]
|
||||
// Mirror the converter: an array without `items` only type-checks.
|
||||
if (!prop.items) return []
|
||||
const items = prop.items
|
||||
return value.flatMap((el, i) => checkValue(items, el, `${path}[${i}]`))
|
||||
}
|
||||
default: return assertNever(prop.type, 'validateArgs')
|
||||
}
|
||||
// Enum membership, checked uniformly: the converter emits `enum` for any
|
||||
// type ([prop.enum]), so the validator must too. `enum` is `string[]`, so a
|
||||
// non-string value can never be a member — it falls out here, consistent
|
||||
// with the schema the model was given.
|
||||
if (prop.enum && !(prop.enum as unknown[]).includes(value)) {
|
||||
return [`"${path}" must be one of ${JSON.stringify(prop.enum)}`]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/** Collect violations for an object value against a {@link SchemaSpec}. */
|
||||
function checkSpec(spec: SchemaSpec, value: unknown, path: string): string[] {
|
||||
if (!isPlainObject(value)) return [`"${path || 'arguments'}" must be an object`]
|
||||
const violations: string[] = []
|
||||
for (const [key, prop] of Object.entries(spec)) {
|
||||
const propPath = path ? `${path}.${key}` : key
|
||||
const v = value[key]
|
||||
if (v === undefined) {
|
||||
// A required key absent OR present-but-undefined is a violation; an
|
||||
// optional absent key is fine. `default` is NOT applied (validation only).
|
||||
if (prop.required === true) violations.push(`missing required property "${propPath}"`)
|
||||
continue
|
||||
}
|
||||
violations.push(...checkValue(prop, v, propPath))
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate model-generated `args` against a {@link SchemaSpec}, returning a
|
||||
* list of human-readable violation messages (empty = valid). Total — never
|
||||
* throws, regardless of how malformed `args` is.
|
||||
*
|
||||
* Semantics mirror {@link schemaSpecToJsonSchema} exactly: the top level must
|
||||
* be a non-array object; required keys come only from `required: true`; extra
|
||||
* keys are allowed (no `additionalProperties: false`); `default` is not
|
||||
* applied; an `object`/`array` prop without `properties`/`items` only
|
||||
* type-checks; `enum` is membership (strings only).
|
||||
* @param spec - the declared parameter schema to validate against.
|
||||
* @param args - the model-generated arguments, however malformed.
|
||||
* @returns the violation messages in declaration order; empty means valid.
|
||||
* Validate model-generated arguments against an implicit parameter schema.
|
||||
* @param spec - declared parameter schema.
|
||||
* @param args - candidate arguments, however malformed.
|
||||
* @returns Path-qualified violations; empty means valid.
|
||||
*/
|
||||
export function validateArgs(spec: SchemaSpec, args: unknown): string[] {
|
||||
return checkSpec(spec, args, '')
|
||||
export function validateArgs(spec: ParameterSchemaSpec, args: unknown): string[] {
|
||||
return validateJsonSchemaValue(parameterSchemaSpecToJsonSchema(spec), args, '')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// defineTool — typed helper for first-party plugin authors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Options for {@link defineTool}. */
|
||||
export interface DefineToolOptions<S extends SchemaSpec> {
|
||||
export interface DefineToolOptions<S extends ParameterSchemaSpec, O extends ValueSchemaSpec> {
|
||||
/** Tool name (must be unique). */
|
||||
readonly name: string
|
||||
/** Human-readable description sent to the model. */
|
||||
readonly description: string
|
||||
/**
|
||||
* Parameter schema using the per-property-required DSL. Converted to
|
||||
* standard JSON Schema at runtime.
|
||||
*/
|
||||
/** Per-property parameter schema compiled to an implicit open object root. */
|
||||
readonly parameters: S
|
||||
/**
|
||||
* Optional cooperative tool-call timeout budget in milliseconds. When given it
|
||||
* must be a positive finite number; it is attached to the produced
|
||||
* {@link ToolDefinition} for `@deepseek-ai/dsh-timeout-policy` to enforce and
|
||||
* is never sent to the model.
|
||||
*/
|
||||
/** Canonical output schema plus pure Native and presentation projections. */
|
||||
readonly output: {
|
||||
/** Schema enforced against every successful body or policy-replaced value. */
|
||||
readonly schema: O
|
||||
/** Pure Native/model rendering of one validated canonical value. */
|
||||
render(args: InferArgs<S>, value: InferValue<NoInfer<O>>): ContentBlock[]
|
||||
/** Pure replayable presentation metadata for direct surface calls. */
|
||||
presentationMeta?(args: InferArgs<S>, value: InferValue<NoInfer<O>>): JsonValue
|
||||
}
|
||||
/** Optional positive cooperative timeout budget in milliseconds. */
|
||||
readonly timeoutMs?: number
|
||||
/**
|
||||
* Optional pure synchronous classifier for sibling overlap. It receives typed
|
||||
* arguments after soft validation; invalid input returns `false` without
|
||||
* invoking it. See {@link ToolDefinition.isConcurrencySafe}.
|
||||
* Pure classifier for sibling overlap.
|
||||
* @param args - typed validated arguments.
|
||||
* @returns whether this call may join a parallel group.
|
||||
* @returns Whether the call may join a parallel group.
|
||||
*/
|
||||
isConcurrencySafe?(args: InferArgs<S>): boolean
|
||||
/**
|
||||
* Tool execution function. `args` is typed as {@link InferArgs<S>} — zero
|
||||
* casts needed. Returns either a bare {@link ContentBlock}`[]` (model-facing
|
||||
* content only) or a `{ content, meta }` object to also attach a tool-private
|
||||
* presentation payload (see {@link ToolExecuteReturn}).
|
||||
* Execute the tool after argument validation.
|
||||
* @param args - typed validated arguments.
|
||||
* @param exec - execution identity, caller, cancellation, and nesting data.
|
||||
* @returns The canonical value declared by `output.schema`.
|
||||
*/
|
||||
execute(args: InferArgs<S>, exec: ToolRunContext): Promise<ToolExecuteReturn>
|
||||
execute(args: InferArgs<S>, exec: ToolRunContext): Promise<InferValue<NoInfer<O>>>
|
||||
/**
|
||||
* Optional last-mile content transform for every normalized outcome. Unlike
|
||||
* `execute`, arguments remain `unknown` because invalid-input failures also
|
||||
@@ -312,39 +521,40 @@ export interface DefineToolOptions<S extends SchemaSpec> {
|
||||
*/
|
||||
finalizeContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined
|
||||
/**
|
||||
* Optional: how to present the PENDING state of one call in a UI (an editor
|
||||
* tool-call card, a CLI log line). `args` is the typed, schema-validated
|
||||
* argument shape — zero casts. Pure and side-effect-free: a UI may call it
|
||||
* during live streaming AND a session-log replay, so depend only on `args`.
|
||||
* The tool owns its presentation so a UI never special-cases tool names. See
|
||||
* {@link ToolCallView}.
|
||||
* Pure pending-state presenter.
|
||||
* @param args - typed validated arguments.
|
||||
* @returns Tool-owned render intent, or `undefined` for the generic card.
|
||||
*/
|
||||
presentCall?(args: InferArgs<S>): ToolCallView | undefined
|
||||
/**
|
||||
* Optional: how to present the COMPLETED state, given the typed `args` and the
|
||||
* `result`. Use it to reformat result content for a UI distinctly from the
|
||||
* model-facing text (e.g. a fenced ```console block). Pure and side-effect-
|
||||
* free for the same replay reason. See {@link ToolResultView}.
|
||||
* Pure completed-state presenter.
|
||||
* @param args - typed validated arguments.
|
||||
* @param result - final model-facing tool result.
|
||||
* @returns Tool-owned render intent, or `undefined` for the generic card.
|
||||
*/
|
||||
presentResult?(args: InferArgs<S>, result: ToolResult): ToolResultView | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Define a first-party tool whose execution and presentation arguments are
|
||||
* inferred from its per-property schema. Raw JSON-Schema definitions remain
|
||||
* valid inputs to {@link ToolRegistry.register}; this helper is authoring sugar.
|
||||
* @param options - the tool's name, description, typed parameter schema,
|
||||
* execute body, and optional finalization/presentation callbacks.
|
||||
* @returns a registry-ready definition with strict execution validation and
|
||||
* soft presenter and classifier validation for replay compatibility.
|
||||
* Define a first-party tool with inferred arguments and strict execution
|
||||
* validation. Replay-only presenters validate softly and fall back to generic
|
||||
* rendering for obsolete logged arguments.
|
||||
* @param options - typed definition and optional finalizer and presenters.
|
||||
* @returns A registry-ready definition.
|
||||
*/
|
||||
export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>): ToolDefinition {
|
||||
// Object-literal execute methods don't use `this`; the reference is safe.
|
||||
export function defineTool<const S extends ParameterSchemaSpec, const O extends ValueSchemaSpec>(
|
||||
options: DefineToolOptions<S, O>,
|
||||
): ToolDefinition {
|
||||
// Object-literal methods do not use `this`; retaining references is safe.
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const userExecute = options.execute
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const userFinalizeContent = options.finalizeContent
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const userRender = options.output.render
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const userPresentationMeta = options.output.presentationMeta
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const userPresentCall = options.presentCall
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const userPresentResult = options.presentResult
|
||||
@@ -353,19 +563,29 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
|
||||
if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) {
|
||||
throw new Error(`defineTool(${options.name}): timeoutMs must be a positive finite number`)
|
||||
}
|
||||
const parameters = parameterSchemaSpecToJsonSchema(options.parameters)
|
||||
const outputSchema = valueSchemaSpecToJsonSchema(options.output.schema)
|
||||
const validate = (args: unknown): string[] => validateJsonSchemaValue(parameters, args, '')
|
||||
const tool: ToolDefinition = {
|
||||
name: options.name,
|
||||
description: options.description,
|
||||
parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>,
|
||||
parameters: parameters as unknown as Record<string, unknown>,
|
||||
output: {
|
||||
schema: outputSchema,
|
||||
render(args: unknown, value: JsonValue): ContentBlock[] {
|
||||
return userRender(args as InferArgs<S>, value as unknown as InferValue<NoInfer<O>>)
|
||||
},
|
||||
...userPresentationMeta !== undefined ? {
|
||||
presentationMeta(args: unknown, value: JsonValue): JsonValue {
|
||||
return userPresentationMeta(args as InferArgs<S>, value as unknown as InferValue<NoInfer<O>>)
|
||||
},
|
||||
} : {},
|
||||
},
|
||||
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
|
||||
async execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn> {
|
||||
// Validate the model-generated args before the typed body runs. On
|
||||
// mismatch we throw ToolArgsError; the registry turns it into an
|
||||
// isError result so the model can self-correct. After this guard, the
|
||||
// cast to InferArgs<S> reflects the validated shape.
|
||||
const violations = validateArgs(options.parameters, args)
|
||||
async execute(args: unknown, exec: ToolRunContext): Promise<JsonValue> {
|
||||
const violations = validate(args)
|
||||
if (violations.length > 0) throw new ToolArgsError(violations)
|
||||
return userExecute(args as InferArgs<S>, exec)
|
||||
return userExecute(args as InferArgs<S>, exec) as Promise<JsonValue>
|
||||
},
|
||||
}
|
||||
if (userFinalizeContent) {
|
||||
@@ -377,20 +597,19 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
|
||||
// than the hard `ToolArgsError` the execute path raises.
|
||||
if (userPresentCall) {
|
||||
tool.presentCall = (args: unknown): ToolCallView | undefined => {
|
||||
if (validateArgs(options.parameters, args).length > 0) return undefined
|
||||
if (validate(args).length > 0) return undefined
|
||||
return userPresentCall(args as InferArgs<S>)
|
||||
}
|
||||
}
|
||||
if (userPresentResult) {
|
||||
tool.presentResult = (args: unknown, result: ToolResult): ToolResultView | undefined => {
|
||||
if (validateArgs(options.parameters, args).length > 0) return undefined
|
||||
if (validate(args).length > 0) return undefined
|
||||
return userPresentResult(args as InferArgs<S>, result)
|
||||
}
|
||||
}
|
||||
// Invalid arguments fail closed without invoking the typed classifier.
|
||||
if (userIsConcurrencySafe) {
|
||||
tool.isConcurrencySafe = (args: unknown): boolean => {
|
||||
if (validateArgs(options.parameters, args).length > 0) return false
|
||||
if (validate(args).length > 0) return false
|
||||
return userIsConcurrencySafe(args as InferArgs<S>)
|
||||
}
|
||||
}
|
||||
|
||||
42
packages/core/tools/src/testing.ts
Normal file
42
packages/core/tools/src/testing.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/** Canonical tool-definition fixtures for repository tests. @module dsh-tools/testing */
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||
import { defineTool } from './schema.ts'
|
||||
import type { DefineToolOptions, ParameterSchemaSpec } from './schema.ts'
|
||||
import type { ToolDefinition, ToolRunContext } from './index.ts'
|
||||
|
||||
const CONTENT_VALUE_SCHEMA = { type: 'array', items: { type: 'json' } } as const
|
||||
|
||||
/** Options for a fixture whose canonical value is its rendered content array. */
|
||||
export type ContentToolFixtureOptions<S extends ParameterSchemaSpec> = Omit<
|
||||
DefineToolOptions<S, typeof CONTENT_VALUE_SCHEMA>,
|
||||
'output' | 'execute'
|
||||
> & {
|
||||
/** Produce the fixture's content blocks as its canonical test value. */
|
||||
execute(args: import('./schema.ts').InferArgs<S>, exec: ToolRunContext): Promise<ContentBlock[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Define a test fixture that deliberately uses its content blocks as the
|
||||
* canonical JSON value. Product tools must declare domain-owned DTOs instead.
|
||||
* @param options - ordinary fixture fields plus a content-producing body.
|
||||
* @returns a registry-ready tool with an explicit JSON-array output contract.
|
||||
* @internal
|
||||
*/
|
||||
export function defineContentToolFixture<const S extends ParameterSchemaSpec>(
|
||||
options: ContentToolFixtureOptions<S>,
|
||||
): ToolDefinition {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const execute = options.execute
|
||||
return defineTool({
|
||||
...options,
|
||||
output: {
|
||||
schema: CONTENT_VALUE_SCHEMA,
|
||||
render: (_args, value) => value as unknown as ContentBlock[],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
return await execute(args, exec) as unknown as JsonValue[]
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -7,6 +7,13 @@
|
||||
*/
|
||||
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import { assertSupportedJsonSchema } from './json-schema.ts'
|
||||
import type { JsonSchemaNode, JsonSchemaScalar } from './json-schema.ts'
|
||||
/** Internal Code Mode projection: the model-facing schema plus the canonical output schema. */
|
||||
export interface ToolSdkSchema extends ToolSchema {
|
||||
/** Validated canonical value returned by the tool binding. */
|
||||
output: JsonSchemaNode
|
||||
}
|
||||
|
||||
/** Property names that are valid bare TS identifiers; anything else is quoted. */
|
||||
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
||||
@@ -30,48 +37,212 @@ function docLines(description: unknown, indent: number): string[] {
|
||||
return [`${pad(indent)}/** ${collapsed.replaceAll('*/', String.raw`*\/`)} */`]
|
||||
}
|
||||
|
||||
/** Render one scalar already validated by the unified schema boundary. */
|
||||
function renderScalar(value: JsonSchemaScalar): string {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
/** Render a validated scalar `const`/`enum`, falling back to the broad type. */
|
||||
function renderConstrainedScalar(node: Record<string, unknown>, type: string): string {
|
||||
const broad = type === 'integer' ? 'number' : type
|
||||
if (Object.hasOwn(node, 'const')) return renderScalar(node.const as JsonSchemaScalar)
|
||||
if (Object.hasOwn(node, 'enum')) {
|
||||
return (node.enum as JsonSchemaScalar[]).map(renderScalar).join(' | ')
|
||||
}
|
||||
return broad
|
||||
}
|
||||
|
||||
/** A composable type document that can be flattened without recursive string concatenation. */
|
||||
interface TypeDocument {
|
||||
readonly parts: readonly (string | TypeDocument)[]
|
||||
readonly containsUnionOrIntersection: boolean
|
||||
}
|
||||
|
||||
/** Build one document from captured parts while retaining the legacy array-parenthesization test. */
|
||||
function typeDocumentFrom(parts: readonly (string | TypeDocument)[]): TypeDocument {
|
||||
return {
|
||||
parts,
|
||||
containsUnionOrIntersection: parts.some(part => typeof part === 'string'
|
||||
? part.includes('|') || part.includes('&')
|
||||
: part.containsUnionOrIntersection),
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a small document without an intermediate array at each call site. */
|
||||
function typeDocument(...parts: (string | TypeDocument)[]): TypeDocument {
|
||||
return typeDocumentFrom(parts)
|
||||
}
|
||||
|
||||
/** Flatten a nested document with an explicit work stack. */
|
||||
function flattenTypeDocument(document: TypeDocument): string {
|
||||
const chunks: string[] = []
|
||||
const tasks: (string | TypeDocument)[] = [document]
|
||||
for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
|
||||
if (typeof task === 'string') {
|
||||
chunks.push(task)
|
||||
continue
|
||||
}
|
||||
for (let index = task.parts.length - 1; index >= 0; index--) {
|
||||
const part = task.parts[index]
|
||||
/* v8 ignore next -- the loop is bounded by the captured part count. */
|
||||
if (part !== undefined) tasks.push(part)
|
||||
}
|
||||
}
|
||||
return chunks.join('')
|
||||
}
|
||||
|
||||
/** One explicit call frame for stack-safe schema-to-TypeScript rendering. */
|
||||
interface SchemaRenderFrame {
|
||||
readonly node: JsonSchemaNode
|
||||
readonly indent: number
|
||||
phase: 'start' | 'children'
|
||||
kind?: 'oneOf' | 'array' | 'object'
|
||||
children: { node: JsonSchemaNode; indent: number }[]
|
||||
childIndex: number
|
||||
childDocuments: TypeDocument[]
|
||||
entries: [string, JsonSchemaNode][]
|
||||
}
|
||||
|
||||
/** Initialize one schema-render frame with empty aggregation state. */
|
||||
function schemaRenderFrame(node: JsonSchemaNode, indent: number): SchemaRenderFrame {
|
||||
return { node, indent, phase: 'start', children: [], childIndex: 0, childDocuments: [], entries: [] }
|
||||
}
|
||||
|
||||
/** Render an already asserted schema to a composable document. */
|
||||
function renderSupportedSchema(schema: JsonSchemaNode, indent: number): TypeDocument {
|
||||
const frames: SchemaRenderFrame[] = [schemaRenderFrame(schema, indent)]
|
||||
let rootDocument: TypeDocument | undefined
|
||||
const finish = (document: TypeDocument): void => {
|
||||
frames.pop()
|
||||
const parent = frames.at(-1)
|
||||
if (parent === undefined) rootDocument = document
|
||||
else parent.childDocuments.push(document)
|
||||
}
|
||||
|
||||
while (frames.length > 0) {
|
||||
const frame = frames.at(-1)
|
||||
/* v8 ignore next -- the loop condition guarantees a current frame. */
|
||||
if (frame === undefined) break
|
||||
if (frame.phase === 'children') {
|
||||
if (frame.childIndex < frame.children.length) {
|
||||
const child = frame.children[frame.childIndex]
|
||||
/* v8 ignore next -- childIndex is bounded by children.length. */
|
||||
if (child === undefined) throw new Error('missing schema render child')
|
||||
frame.childIndex++
|
||||
frames.push(schemaRenderFrame(child.node, child.indent))
|
||||
continue
|
||||
}
|
||||
if (frame.kind === 'oneOf') {
|
||||
const parts: (string | TypeDocument)[] = []
|
||||
for (let index = 0; index < frame.childDocuments.length; index++) {
|
||||
if (index > 0) parts.push(' | ')
|
||||
const child = frame.childDocuments[index]
|
||||
/* v8 ignore next -- child documents correspond one-to-one with children. */
|
||||
if (child !== undefined) parts.push(child)
|
||||
}
|
||||
finish(typeDocumentFrom(parts))
|
||||
continue
|
||||
}
|
||||
if (frame.kind === 'array') {
|
||||
const child = frame.childDocuments[0]
|
||||
/* v8 ignore next -- array frames always schedule exactly one child. */
|
||||
if (child === undefined) throw new Error('missing array item type')
|
||||
finish(child.containsUnionOrIntersection
|
||||
? typeDocument('(', child, ')[]')
|
||||
: typeDocument(child, '[]'))
|
||||
continue
|
||||
}
|
||||
|
||||
const required = new Set(frame.node.required)
|
||||
const parts: (string | TypeDocument)[] = ['{']
|
||||
for (let index = 0; index < frame.entries.length; index++) {
|
||||
const entry = frame.entries[index]
|
||||
const child = frame.childDocuments[index]
|
||||
/* v8 ignore next -- object entries and child documents have the same length. */
|
||||
if (entry === undefined || child === undefined) throw new Error('missing object property type')
|
||||
const [name, prop] = entry
|
||||
for (const line of docLines(prop.description, frame.indent + 1)) parts.push('\n', line)
|
||||
parts.push('\n', `${pad(frame.indent + 1)}${renderKey(name)}${required.has(name) ? '' : '?'}: `, child, ';')
|
||||
}
|
||||
parts.push('\n', `${pad(frame.indent)}}`)
|
||||
const declared = typeDocumentFrom(parts)
|
||||
finish(frame.node.additionalProperties === false
|
||||
? declared
|
||||
: typeDocument(declared, ' & Record<string, JsonValue>'))
|
||||
continue
|
||||
}
|
||||
|
||||
const node = frame.node
|
||||
if (node.oneOf !== undefined) {
|
||||
frame.kind = 'oneOf'
|
||||
frame.children = Array.from(node.oneOf, child => ({ node: child, indent: frame.indent }))
|
||||
frame.childIndex = 0
|
||||
frame.childDocuments = []
|
||||
frame.phase = 'children'
|
||||
continue
|
||||
}
|
||||
if (node.type === undefined) {
|
||||
finish(typeDocument('JsonValue'))
|
||||
continue
|
||||
}
|
||||
switch (node.type) {
|
||||
case 'string':
|
||||
case 'number':
|
||||
case 'integer':
|
||||
case 'boolean':
|
||||
case 'null':
|
||||
finish(typeDocument(renderConstrainedScalar(node as Record<string, unknown>, node.type)))
|
||||
break
|
||||
case 'array':
|
||||
if (node.items === undefined) {
|
||||
finish(typeDocument('JsonValue[]'))
|
||||
} else {
|
||||
frame.kind = 'array'
|
||||
frame.children = [{ node: node.items, indent: frame.indent }]
|
||||
frame.childIndex = 0
|
||||
frame.childDocuments = []
|
||||
frame.phase = 'children'
|
||||
}
|
||||
break
|
||||
case 'object': {
|
||||
const open = node.additionalProperties !== false
|
||||
const entries = Object.entries(node.properties ?? {})
|
||||
if (entries.length === 0) {
|
||||
finish(typeDocument(open ? 'Record<string, JsonValue>' : 'Record<string, never>'))
|
||||
} else {
|
||||
frame.kind = 'object'
|
||||
frame.entries = entries
|
||||
frame.children = entries.map(([, child]) => ({ node: child, indent: frame.indent + 1 }))
|
||||
frame.childIndex = 0
|
||||
frame.childDocuments = []
|
||||
frame.phase = 'children'
|
||||
}
|
||||
break
|
||||
}
|
||||
/* v8 ignore next -- assertSupportedJsonSchema narrowed this closed type union. */
|
||||
default:
|
||||
finish(typeDocument('unknown'))
|
||||
}
|
||||
}
|
||||
|
||||
/* v8 ignore next -- every root frame produces one document. */
|
||||
return rootDocument ?? typeDocument('unknown')
|
||||
}
|
||||
|
||||
/**
|
||||
* Map one JSON-Schema node to a TypeScript type literal. Handles exactly the
|
||||
* subset the `defineTool` DSL emits — `object` (`properties` + `required`),
|
||||
* `string` (with `enum` → a literal union), `number`, `boolean`, `array`
|
||||
* (`items`) — and returns `unknown` for anything else, without throwing.
|
||||
* Map one enforced JSON-Schema node to a TypeScript type literal. Supports
|
||||
* every unified schema construct and returns `unknown` for malformed or
|
||||
* unsupported inputs without throwing.
|
||||
* @param schema - the JSON-Schema node (any shape; hostile inputs degrade).
|
||||
* @param indent - the indentation level for nested object members.
|
||||
* @returns the TS type text (multi-line for objects with properties).
|
||||
*/
|
||||
export function jsonSchemaToTs(schema: unknown, indent = 0): string {
|
||||
if (typeof schema !== 'object' || schema === null) return 'unknown'
|
||||
const node = schema as Record<string, unknown>
|
||||
switch (node.type) {
|
||||
case 'string': {
|
||||
if (Array.isArray(node.enum) && node.enum.length > 0 && node.enum.every(value => typeof value === 'string')) {
|
||||
return node.enum.map(value => JSON.stringify(value)).join(' | ')
|
||||
}
|
||||
return 'string'
|
||||
}
|
||||
case 'number': return 'number'
|
||||
case 'boolean': return 'boolean'
|
||||
case 'array': {
|
||||
const item = jsonSchemaToTs(node.items, indent)
|
||||
// Parenthesize a union item type so `('a' | 'b')[]` parses as intended.
|
||||
return item.includes('|') ? `(${item})[]` : `${item}[]`
|
||||
}
|
||||
case 'object': {
|
||||
const properties = node.properties
|
||||
if (typeof properties !== 'object' || properties === null) return 'Record<string, unknown>'
|
||||
const entries = Object.entries(properties as Record<string, unknown>)
|
||||
if (entries.length === 0) return 'Record<string, unknown>'
|
||||
const required = new Set(Array.isArray(node.required) ? node.required.filter(name => typeof name === 'string') : [])
|
||||
const lines: string[] = ['{']
|
||||
for (const [name, prop] of entries) {
|
||||
const description = typeof prop === 'object' && prop !== null ? (prop as Record<string, unknown>).description : undefined
|
||||
lines.push(...docLines(description, indent + 1))
|
||||
lines.push(`${pad(indent + 1)}${renderKey(name)}${required.has(name) ? '' : '?'}: ${jsonSchemaToTs(prop, indent + 1)};`)
|
||||
}
|
||||
lines.push(`${pad(indent)}}`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
default: return 'unknown'
|
||||
try {
|
||||
assertSupportedJsonSchema(schema)
|
||||
return flattenTypeDocument(renderSupportedSchema(schema, indent))
|
||||
} catch {
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,8 +251,8 @@ const SDK_INSTRUCTIONS = `## Writing code for run_code
|
||||
|
||||
Pass \`run_code\` the body of an async TypeScript function (erasable syntax only — no \`enum\` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:
|
||||
|
||||
- Call tools as \`await tools.name(args)\` — quoted access for exotic names: \`tools["my-tool"](args)\`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable.
|
||||
- A FAILED tool call rejects with an \`Error\` carrying the tool's error text — \`try/catch\` it to handle and continue.
|
||||
- Call tools as \`await tools.name(args)\` — quoted access for exotic names: \`tools["my-tool"](args)\`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.
|
||||
- A FAILED tool call rejects with \`ToolCallError\`, whose \`toolName\` identifies the failed tool and whose \`message\` is human-readable — \`try/catch\` it to handle and continue.
|
||||
- Calls execute sequentially, even under \`Promise.all\`.
|
||||
- Emit results with \`return\` and/or \`console.log(...)\`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.
|
||||
|
||||
@@ -96,15 +267,24 @@ The available tools:`
|
||||
* `run_code` itself).
|
||||
* @returns the complete section text.
|
||||
*/
|
||||
export function renderToolsSdk(schemas: ToolSchema[]): string {
|
||||
export function renderToolsSdk(schemas: ToolSdkSchema[]): string {
|
||||
const sorted = [...schemas].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
|
||||
const members: string[] = []
|
||||
const argsMembers: string[] = []
|
||||
const outputMembers: string[] = []
|
||||
for (const schema of sorted) {
|
||||
members.push(...docLines(schema.description, 1))
|
||||
members.push(`${pad(1)}${renderKey(schema.name)}(args: ${jsonSchemaToTs(schema.parameters, 1)}): Promise<string>;`)
|
||||
argsMembers.push(...docLines(schema.description, 1))
|
||||
argsMembers.push(`${pad(1)}${renderKey(schema.name)}: ${jsonSchemaToTs(schema.parameters, 1)};`)
|
||||
outputMembers.push(`${pad(1)}${renderKey(schema.name)}: ${jsonSchemaToTs(schema.output, 1)};`)
|
||||
}
|
||||
const declaration = members.length > 0
|
||||
? `declare const tools: {\n${members.join('\n')}\n}`
|
||||
: 'declare const tools: {}'
|
||||
return `${SDK_INSTRUCTIONS}\n\n\`\`\`ts\n${declaration}\n\`\`\``
|
||||
const argsMap = `interface ToolArgsMap {${argsMembers.length > 0 ? `\n${argsMembers.join('\n')}\n` : ''}}`
|
||||
const outputMap = `interface ToolOutputMap {${outputMembers.length > 0 ? `\n${outputMembers.join('\n')}\n` : ''}}`
|
||||
const declaration = [
|
||||
argsMap,
|
||||
outputMap,
|
||||
'type ToolName = keyof ToolOutputMap',
|
||||
['declare class ToolCallError extends Error {', ' readonly name: "ToolCallError";', ' readonly toolName: ToolName;', '}'].join('\n'),
|
||||
['declare const tools: {', ' [K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]>;', '}'].join('\n'),
|
||||
].join('\n\n')
|
||||
const jsonValue = 'type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }'
|
||||
return `${SDK_INSTRUCTIONS}\n\n\`\`\`ts\n${jsonValue}\n\n${declaration}\n\`\`\``
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user