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:
@@ -219,7 +219,10 @@ function appendSkippedToolCall(session: Session, turn: number, step: number, blo
|
||||
appendToolResult(session, turn, step, block, {
|
||||
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 },
|
||||
},
|
||||
}, callSeq)
|
||||
}
|
||||
|
||||
@@ -245,7 +248,7 @@ function appendToolResult(
|
||||
callId: block.id,
|
||||
content: result.content,
|
||||
isError: result.isError,
|
||||
...result.error ? { error: result.error } : {},
|
||||
...result.error?.info ? { error: result.error.info } : {},
|
||||
// The tool's private presentation payload (e.g. a result-time diff),
|
||||
// persisted so a UI bridge reproduces the card on replay.
|
||||
...result.meta !== undefined ? { meta: result.meta } : {},
|
||||
|
||||
@@ -6,7 +6,7 @@ import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
@@ -189,7 +189,7 @@ describe('AgentLoop initiator scope', () => {
|
||||
ctx.on('agent/turn-stop', (subject, _turn, signal) => {
|
||||
if (subject === agent) capture(signal)
|
||||
})
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'observe',
|
||||
description: 'observe explicit turn state',
|
||||
parameters: {},
|
||||
@@ -232,7 +232,7 @@ describe('AgentLoop initiator scope', () => {
|
||||
let parentWhileChildDriverActive: Agent | undefined
|
||||
let child: Agent | undefined
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'spawn-child',
|
||||
description: 'create one child agent',
|
||||
parameters: {},
|
||||
@@ -244,7 +244,7 @@ describe('AgentLoop initiator scope', () => {
|
||||
setup: (agentCtx) => {
|
||||
parentDuringSetup = ctx.agents.requireInitiator()
|
||||
explicitChild = agentCtx.agent
|
||||
agentCtx.tools.register(defineTool({
|
||||
agentCtx.tools.register(defineContentToolFixture({
|
||||
name: 'observe-child',
|
||||
description: 'observe child execution identity',
|
||||
parameters: {},
|
||||
@@ -292,7 +292,7 @@ describe('AgentLoop initiator scope', () => {
|
||||
let directAmbient: Agent | undefined
|
||||
let captured: Agent | undefined
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'agentless-probe',
|
||||
description: 'observe an agentless call',
|
||||
parameters: {},
|
||||
@@ -302,7 +302,7 @@ describe('AgentLoop initiator scope', () => {
|
||||
return [{ type: 'text', text: 'ok' }]
|
||||
},
|
||||
}))
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'capability-request',
|
||||
description: 'call the test capability transport',
|
||||
parameters: { path: { type: 'string' } },
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Context } from 'cordis'
|
||||
import LlmService, { type Message } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
|
||||
import ToolRegistry, { defineContentToolFixture, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
@@ -365,7 +365,7 @@ describe('Agent.cancel()', () => {
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
let executions = 0
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'danger',
|
||||
description: 'must not run after cancellation',
|
||||
parameters: {},
|
||||
@@ -952,7 +952,7 @@ describe('Agent.cancel()', () => {
|
||||
})
|
||||
break
|
||||
case 'tool':
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'blocked',
|
||||
description: 'wait for cancellation',
|
||||
parameters: {},
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Context } from 'cordis'
|
||||
import LlmService, { CallId, ContentBlock, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool, TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
import ToolRegistry, { defineContentToolFixture, TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent, type ContinuationDecision, type HookContext } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { prepareReactLoopAgent } from '../src/agent.ts'
|
||||
@@ -60,7 +60,7 @@ describe('session log records what agent/step-result actually produced', () => {
|
||||
const adapter = new MockAdapter([original, textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
const executed: string[] = []
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'injected-tool',
|
||||
description: '',
|
||||
parameters: {},
|
||||
@@ -229,7 +229,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const executed: string[] = []
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'aborter',
|
||||
description: '',
|
||||
parameters: {},
|
||||
@@ -250,7 +250,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
source: { kind: 'plugin', plugin: 'abort-test' },
|
||||
}],
|
||||
}))
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'second',
|
||||
description: '',
|
||||
parameters: {},
|
||||
@@ -332,7 +332,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'aborter', {})])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a-abort-injection'), { provider: 'mock', model: 'mock' })
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'aborter',
|
||||
description: '',
|
||||
parameters: {},
|
||||
@@ -378,7 +378,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
] satisfies StreamChunk[]])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a-later-abort-context'), { provider: 'mock', model: 'mock' })
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'first',
|
||||
description: '',
|
||||
parameters: {},
|
||||
@@ -386,7 +386,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
return [{ type: 'text', text: 'first done' }]
|
||||
},
|
||||
}))
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'aborter',
|
||||
description: '',
|
||||
parameters: {},
|
||||
@@ -427,7 +427,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(SessionId('a-dispose-injection'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'waiter',
|
||||
description: '',
|
||||
parameters: {},
|
||||
@@ -479,7 +479,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a-historical-tool-pair'), { provider: 'mock', model: 'mock' })
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'aborter',
|
||||
description: '',
|
||||
parameters: {},
|
||||
@@ -488,7 +488,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
return [{ type: 'text', text: 'done' }]
|
||||
},
|
||||
}))
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'second',
|
||||
description: '',
|
||||
parameters: {},
|
||||
@@ -767,7 +767,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'noop',
|
||||
description: '',
|
||||
parameters: {},
|
||||
@@ -850,7 +850,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
|
||||
const agent = ctx.agentLoop.create(SessionId('owned-steer'), { provider: 'mock', model: 'mock' })
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'gate',
|
||||
description: '',
|
||||
parameters: {},
|
||||
@@ -1497,7 +1497,7 @@ describe('tool result call identity', () => {
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'echo',
|
||||
description: 'echo',
|
||||
parameters: { x: { type: 'number' } },
|
||||
|
||||
@@ -4,7 +4,7 @@ import LlmService, { CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
@@ -77,7 +77,7 @@ describe('tool JSON parse', () => {
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'echo',
|
||||
description: 'echo tool',
|
||||
parameters: { input: { type: 'string' } },
|
||||
@@ -110,7 +110,7 @@ describe('tool JSON parse', () => {
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'noarg',
|
||||
description: 'no-arg tool',
|
||||
parameters: {},
|
||||
@@ -259,7 +259,7 @@ describe('structured tool error propagation (the runtime-validation Agent Note,
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'boom',
|
||||
description: 'always fails',
|
||||
parameters: {},
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Context } from 'cordis'
|
||||
import LlmService, { CallId, type Message } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
import ToolRegistry, { defineContentToolFixture, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent, type ContinuationDecision, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
@@ -399,7 +399,7 @@ describe('agent/session-prefix', () => {
|
||||
textResponse('again'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
|
||||
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
|
||||
}))
|
||||
@@ -521,7 +521,7 @@ describe('agent/session-prefix', () => {
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
|
||||
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
|
||||
}))
|
||||
@@ -574,7 +574,7 @@ describe('agent/turn-continuation (ContinuationDecision)', () => {
|
||||
it('a stop decision ends the turn even when the step had tool calls', async () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'hi' })])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
|
||||
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
|
||||
}))
|
||||
@@ -604,7 +604,7 @@ describe('tool additionalContexts buffering across a step', () => {
|
||||
]
|
||||
const adapter = new MockAdapter([twoCalls, textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
|
||||
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
|
||||
}))
|
||||
@@ -646,7 +646,7 @@ describe('tool additionalContexts buffering across a step', () => {
|
||||
it('appends multiple contexts deferred by one composite tool after its outer result', async () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'composite', {}), textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'composite', description: 'composite', parameters: {},
|
||||
async execute(_args, exec) {
|
||||
exec.deferContext({ content: [{ type: 'text', text: 'nested-a' }], source: { kind: 'plugin', plugin: 'a' }, meta: { order: 1 } })
|
||||
@@ -677,7 +677,7 @@ describe('tools/pre-execute gate (native-plugin permission pattern, end-to-end t
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'danger', {}), textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
let ran = false
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'danger', description: 'danger', parameters: {},
|
||||
async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] },
|
||||
}))
|
||||
@@ -739,7 +739,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'hi' }), textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
await ctx.plugin(NativeGuard)
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
|
||||
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
|
||||
}))
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SessionId, TurnEndReason, type JsonValue } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import ToolRegistry, { defineContentToolFixture, defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
@@ -89,7 +89,7 @@ describe('agent loop', () => {
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'echo',
|
||||
description: 'echo back',
|
||||
parameters: { text: { type: 'string' } },
|
||||
@@ -118,22 +118,27 @@ describe('agent loop', () => {
|
||||
const types = agent.session.events.map(e => e.type)
|
||||
expect(types).toContain('tool/call')
|
||||
expect(types).toContain('tool/result')
|
||||
const durableResult = agent.session.events.find(event => event.type === 'tool/result')
|
||||
expect(durableResult?.type === 'tool/result' && 'value' in durableResult.data).toBe(false)
|
||||
})
|
||||
|
||||
it('threads a tool-attached meta (execute object return) onto the tool/result event', async () => {
|
||||
it('persists presentation metadata projected from the canonical value', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'writer', { path: 'a.txt' }, 'writing'),
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
// A tool that returns the { content, meta } object form: the loop must
|
||||
// persist `meta` on the tool/result event so a UI reproduces the card on replay.
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'writer',
|
||||
description: 'writes a file',
|
||||
parameters: { path: { type: 'string' } },
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: () => [{ type: 'text', text: 'ok' }],
|
||||
presentationMeta: (_args, value) => ({ diffs: [{ path: value, oldText: null, newText: 'x' }] }),
|
||||
},
|
||||
async execute() {
|
||||
return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a.txt', oldText: null, newText: 'x' }] } }
|
||||
return 'a.txt'
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
@@ -152,7 +157,7 @@ describe('agent loop', () => {
|
||||
// projecting this agent's configured model, so the model knows its own name.
|
||||
const ctx = await harness(adapter, 'You are a test agent on {{model}}.')
|
||||
ctx.systemPrompt.section({ name: 'tool:noop', order: 100, text: 'Use the noop tool wisely.' })
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'noop',
|
||||
description: 'does nothing',
|
||||
parameters: {},
|
||||
@@ -248,7 +253,7 @@ describe('agent loop', () => {
|
||||
['BigInt', { n: 1n }],
|
||||
['Map', new Map([['key', 'value']])],
|
||||
['class instance', new (class ResultMeta { x = 1 })()],
|
||||
])('normalizes non-JSON tool meta (%s) before the durable result commit', async (_kind, meta) => {
|
||||
])('rejects non-JSON presentation metadata (%s) before the durable result commit', async (_kind, meta) => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('bad-meta-call', 'bad-meta', {}, 'calling'),
|
||||
textResponse('recovered'),
|
||||
@@ -258,7 +263,12 @@ describe('agent loop', () => {
|
||||
name: 'bad-meta',
|
||||
description: 'returns invalid durable metadata',
|
||||
parameters: {},
|
||||
execute: () => Promise.resolve({ content: [{ type: 'text' as const, text: 'apparent success' }], meta }),
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args, value) => [{ type: 'text', text: value }],
|
||||
presentationMeta: () => meta as unknown as JsonValue,
|
||||
},
|
||||
execute: () => Promise.resolve('apparent success'),
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(SessionId('bad-meta-agent'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
@@ -271,15 +281,16 @@ describe('agent loop', () => {
|
||||
expect(result.data.callId).toBe('bad-meta-call')
|
||||
expect(result.data.isError).toBe(true)
|
||||
expect(result.data.meta).toBeUndefined()
|
||||
expect(result.data.error).toEqual({ name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' })
|
||||
expect(result.data.content).toEqual([{
|
||||
type: 'text',
|
||||
text: 'Error: tool result must be losslessly JSON-serializable',
|
||||
text: 'Error: tool "bad-meta" returned invalid output: output.presentationMeta returned non-lossless JSON',
|
||||
}])
|
||||
}
|
||||
// The normalized failure was durably logged and fed back to the model; the
|
||||
// turn continued normally instead of failing after an apparent success.
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('losslessly JSON-serializable')
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('output.presentationMeta returned non-lossless JSON')
|
||||
})
|
||||
|
||||
it('omits the system field when a system-prompt/assemble veto empties the assembly', async () => {
|
||||
@@ -326,7 +337,7 @@ describe('agent loop', () => {
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'slow',
|
||||
description: '',
|
||||
parameters: {},
|
||||
@@ -432,7 +443,7 @@ describe('agent loop', () => {
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
let visibleDuringTool = false
|
||||
const meta = { kind: 'deferred-test', version: 1 }
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'noticer',
|
||||
description: 'injects a notice',
|
||||
parameters: {},
|
||||
@@ -494,7 +505,7 @@ describe('agent loop', () => {
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('invalid-context'), { provider: 'mock', model: 'mock' })
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'invalid-injector',
|
||||
description: 'attempts an invalid context injection',
|
||||
parameters: {},
|
||||
@@ -541,7 +552,7 @@ describe('agent loop', () => {
|
||||
it('agent/turn-continuation can veto continuation despite tool calls (budget-guard pattern)', async () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'x' })])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'echo',
|
||||
description: '',
|
||||
parameters: { text: { type: 'string' } },
|
||||
@@ -589,7 +600,7 @@ describe('agent loop', () => {
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'echo', description: 'echo', parameters: {},
|
||||
async execute() { return [{ type: 'text', text: 'echoed' }] },
|
||||
}))
|
||||
@@ -781,7 +792,7 @@ describe('agent loop', () => {
|
||||
]])
|
||||
const ctx = await harness(adapter)
|
||||
let executions = 0
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'echo',
|
||||
description: '',
|
||||
parameters: { text: { type: 'string' } },
|
||||
@@ -821,7 +832,7 @@ describe('agent loop', () => {
|
||||
{ type: 'finish', reason: { kind: 'max-tokens' } },
|
||||
]])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'echo',
|
||||
description: '',
|
||||
parameters: { text: { type: 'string' } },
|
||||
@@ -908,7 +919,7 @@ describe('agent loop', () => {
|
||||
textResponse('continued after tool call'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'echo',
|
||||
description: '',
|
||||
parameters: { text: { type: 'string' } },
|
||||
@@ -1240,7 +1251,7 @@ describe('agent loop', () => {
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'echo',
|
||||
description: '',
|
||||
parameters: { text: { type: 'string' } },
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
@@ -45,7 +45,7 @@ async function loopHarness(): Promise<Context> {
|
||||
await created.plugin(AgentRegistry)
|
||||
await created.plugin(AgentLoop, { agents: [] })
|
||||
await created.plugin(LlmDeepSeek)
|
||||
created.tools.register(defineTool({
|
||||
created.tools.register(defineContentToolFixture({
|
||||
name: 'lookup',
|
||||
description: 'Look up the stored value for a key.',
|
||||
parameters: { key: { type: 'string', description: 'The key to look up.' } },
|
||||
|
||||
@@ -11,7 +11,7 @@ import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
@@ -53,7 +53,7 @@ function expectPrefixExtension(previous: GenerateOptions, current: GenerateOptio
|
||||
}
|
||||
|
||||
function registerEcho(ctx: Context) {
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'echo',
|
||||
description: 'echo back',
|
||||
parameters: { text: { type: 'string' } },
|
||||
|
||||
@@ -11,7 +11,7 @@ import LlmService, {
|
||||
import type { GenerateOptions, LlmFailure, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import type { PostToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
@@ -133,7 +133,7 @@ describe('agent post-step and request-error lifecycle', () => {
|
||||
]
|
||||
const adapter = new FailureScriptAdapter([twoCalls, textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'work',
|
||||
description: 'do work',
|
||||
parameters: {},
|
||||
@@ -222,7 +222,7 @@ describe('agent post-step and request-error lifecycle', () => {
|
||||
textResponse('must not continue'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'work',
|
||||
description: 'do work',
|
||||
parameters: {},
|
||||
@@ -535,7 +535,7 @@ describe('agent post-step and request-error lifecycle', () => {
|
||||
contextError('later overflow'),
|
||||
])
|
||||
const resetCtx = await harness(reset)
|
||||
resetCtx.tools.register(defineTool({
|
||||
resetCtx.tools.register(defineContentToolFixture({
|
||||
name: 'work',
|
||||
description: 'continue',
|
||||
parameters: {},
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Context, symbols, type EffectMeta, type Fiber } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
@@ -172,10 +172,10 @@ describe('agent scope lifecycle', () => {
|
||||
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
const { agent } = handle
|
||||
agent.ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You run tests.' })
|
||||
agent.ctx.tools.register({
|
||||
agent.ctx.tools.register(defineContentToolFixture({
|
||||
name: 'mine', description: 'scoped', parameters: {},
|
||||
execute: () => Promise.resolve(text('ran')),
|
||||
})
|
||||
}))
|
||||
|
||||
const scopedAssembly = await ctx.systemPrompt.assemble(assembleContextFor(agent))
|
||||
expect(scopedAssembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You run tests.')
|
||||
@@ -587,12 +587,12 @@ describe('agent scope lifecycle', () => {
|
||||
sessionId: SessionId('dependency-origin-s'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
setup: (agentCtx) => {
|
||||
agentCtx.tools.register({
|
||||
agentCtx.tools.register(defineContentToolFixture({
|
||||
name: 'dependency-origin-tool',
|
||||
description: 'proves AgentLoop dependency origin',
|
||||
parameters: {},
|
||||
execute: () => Promise.resolve(text('ok')),
|
||||
})
|
||||
}))
|
||||
agentCtx.systemPrompt.section({
|
||||
name: 'dependency-origin-section',
|
||||
order: 1,
|
||||
|
||||
@@ -9,7 +9,7 @@ import { CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import ToolRegistry, { defineTool, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
import ToolRegistry, { defineContentToolFixture, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
@@ -61,7 +61,7 @@ function multiCall(calls: { id: string; name: string; args: object }[]): StreamC
|
||||
function gatedTool(name: string, parallel: boolean) {
|
||||
const gates = new Map<string, () => void>()
|
||||
const started: string[] = []
|
||||
const tool = defineTool({
|
||||
const tool = defineContentToolFixture({
|
||||
name,
|
||||
description: `gated ${name}`,
|
||||
parameters: { id: { type: 'string', required: true } },
|
||||
@@ -123,12 +123,12 @@ describe('tool-call scheduler: grouping and barriers', () => {
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'r', description: 'read', parameters: { id: { type: 'string', required: true } },
|
||||
isConcurrencySafe: () => true,
|
||||
async execute(args) { order.push(`r-start-${args.id}`); order.push(`r-end-${args.id}`); return [{ type: 'text', text: 'r' }] },
|
||||
}))
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'w', description: 'write', parameters: { id: { type: 'string', required: true } },
|
||||
async execute(args) { order.push(`w-${args.id}`); return [{ type: 'text', text: 'w' }] },
|
||||
}))
|
||||
@@ -150,14 +150,14 @@ describe('tool-call scheduler: grouping and barriers', () => {
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const replacement = gatedExclusiveTool('x')
|
||||
const disposeSafe = ctx.tools.register(defineTool({
|
||||
const disposeSafe = ctx.tools.register(defineContentToolFixture({
|
||||
name: 'x',
|
||||
description: 'initially safe',
|
||||
parameters: { id: { type: 'string', required: true } },
|
||||
isConcurrencySafe: () => true,
|
||||
async execute(args) { return [{ type: 'text', text: `old-${args.id}` }] },
|
||||
}))
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'replace',
|
||||
description: 'replace x',
|
||||
parameters: { id: { type: 'string', required: true } },
|
||||
@@ -539,10 +539,14 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
.toEqual([CallId('c1'), CallId('c2'), CallId('c3'), CallId('c4')])
|
||||
expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId))
|
||||
.toEqual([CallId('c1'), CallId('c2'), CallId('c3'), CallId('c4')])
|
||||
expect(events(agent).filter(e => e.type === 'tool/result').slice(-2).map(e => e.data))
|
||||
expect(events(agent).filter(e => e.type === 'tool/result').slice(-2).map(e => ({
|
||||
callId: e.data.callId,
|
||||
isError: e.data.isError,
|
||||
errorInfo: e.data.error,
|
||||
})))
|
||||
.toEqual([
|
||||
expect.objectContaining({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }),
|
||||
expect.objectContaining({ callId: CallId('c4'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }),
|
||||
{ callId: CallId('c3'), isError: true, errorInfo: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
|
||||
{ callId: CallId('c4'), isError: true, errorInfo: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
|
||||
])
|
||||
const settled = events(agent).filter(e => e.type === 'tool/result' || e.type === 'context/message')
|
||||
expect(settled.map(e => e.type))
|
||||
@@ -565,7 +569,7 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
const gated = gatedParallelTool('p')
|
||||
const exclusive: string[] = []
|
||||
ctx.tools.register(gated.tool)
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'x',
|
||||
description: 'exclusive',
|
||||
parameters: { id: { type: 'string', required: true } },
|
||||
|
||||
@@ -12,7 +12,7 @@ import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt, { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
@@ -42,7 +42,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
}
|
||||
|
||||
function registerNamed(ctx: Context, name: string) {
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name,
|
||||
description: `the ${name} tool`,
|
||||
parameters: {},
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent, type ContinuationStop } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
@@ -39,7 +39,7 @@ function send(agent: Agent, text = 'go'): Promise<void> {
|
||||
}
|
||||
|
||||
function registerEcho(ctx: Context): void {
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'echo',
|
||||
description: 'echo',
|
||||
parameters: { text: { type: 'string' } },
|
||||
|
||||
@@ -46,7 +46,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
|
||||
|
||||
### Lossless JSON utilities
|
||||
|
||||
Durable values need one accepted representation, not a check followed by a second read. `isJsonValue(value)` is the boolean predicate; `snapshotJsonValue(value)` recursively validates and copies a plain value in one pass, returning `undefined` for invalid input and propagating a throwing getter. The snapshot helper accepts finite JSON numbers except `-0` (JSON rewrites it to `0`), dense ordinary arrays, and plain or null-prototype objects; it rejects cycles, unsupported scalars, and exotic prototypes before normalization.
|
||||
Durable values need one accepted representation, not a check followed by a second read. `isJsonValue(value)` is the boolean predicate; `snapshotJsonValue(value)` iteratively validates and copies a plain value in one pass, returning `undefined` for invalid input and propagating a throwing getter. The snapshot helper accepts finite JSON numbers except `-0` (JSON rewrites it to `0`), dense ordinary arrays, and plain or null-prototype objects; it rejects cycles, unsupported scalars, and exotic prototypes before normalization without imposing a call-stack depth limit.
|
||||
|
||||
### Chunk-row storage codec (`chunk-rows.ts`)
|
||||
|
||||
@@ -66,6 +66,8 @@ Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/
|
||||
|
||||
`context/message` renders its `content` verbatim as a user-role message, and may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`. A `user/message` or `steering/message` with prompt-prefix context keeps the exact combined model bytes in `content` and stores a model-hidden `envelope` containing the direct `displayContent` and prefix context source/metadata descriptors. `displayPromptContent()` selects the human-facing prompt without changing derived history.
|
||||
|
||||
`tool/result` persists the model-facing content, optional internal failure identity, and optional presentation metadata. A tool's successful canonical `value` and human-readable canonical failure message remain execution-local; rendered error content is the replay-authoritative message. This preserves the existing event shape and does not change `SESSION_FORMAT_VERSION`.
|
||||
|
||||
### Session event vocabulary (`types.ts`)
|
||||
|
||||
The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message. Provider/model/replay provenance rides on `assistant/message`; an operational error's step is on `turn/end.reason` for `kind: 'error'`, with structured provider facts for a final model-request failure.
|
||||
|
||||
@@ -3,82 +3,179 @@
|
||||
/**
|
||||
* A value that round-trips losslessly through JSON: `null`, a boolean, a finite
|
||||
* number other than negative zero, a string, an array of such values, or a
|
||||
* plain object whose values are such values. TypeScript cannot distinguish
|
||||
* `-0` from `number`, so {@link isJsonValue} and {@link snapshotJsonValue}
|
||||
* enforce that last numeric detail at runtime. Use this type for a payload that
|
||||
* must survive session-log persistence and replay byte-identically — e.g. a
|
||||
* tool's private presentation `meta`.
|
||||
* plain object whose values are such values. Arrays may carry only their dense
|
||||
* indexed elements; extra own properties would be discarded by JSON. TypeScript
|
||||
* cannot distinguish `-0` from `number`, so {@link isJsonValue} and
|
||||
* {@link snapshotJsonValue} enforce these details at runtime. Use this type for
|
||||
* a payload that must survive session-log persistence and replay byte-identically
|
||||
* — e.g. a tool's private presentation `meta`.
|
||||
*/
|
||||
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
|
||||
|
||||
/** 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 {
|
||||
return constructor.name === name
|
||||
&& constructor.prototype === prototype
|
||||
&& Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }`
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a candidate is one realm's intrinsic `Object.prototype`. */
|
||||
function isIntrinsicObjectPrototype(value: object): boolean {
|
||||
return Object.getPrototypeOf(value) === null && hasIntrinsicConstructor(value, 'Object')
|
||||
}
|
||||
|
||||
/** Whether an array uses one realm's intrinsic `Array.prototype`, not a subclass or forged 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)
|
||||
}
|
||||
|
||||
/** Whether an object is a plain or null-prototype record from any JavaScript realm. */
|
||||
function hasPlainObjectPrototype(value: object): boolean {
|
||||
const prototype: unknown = Object.getPrototypeOf(value)
|
||||
return prototype === null
|
||||
|| typeof prototype === 'object' && isIntrinsicObjectPrototype(prototype)
|
||||
}
|
||||
|
||||
/** Return every JSON-visible object key, or reject own data JSON would discard. */
|
||||
function enumerableStringKeys(value: object): string[] | undefined {
|
||||
const keys = Reflect.ownKeys(value)
|
||||
if (keys.some(key => typeof key !== 'string' || !Object.prototype.propertyIsEnumerable.call(value, key))) return undefined
|
||||
return keys as string[]
|
||||
}
|
||||
|
||||
type SnapshotDestination =
|
||||
| { kind: 'root' }
|
||||
| { kind: 'array'; target: JsonValue[]; index: number }
|
||||
| { kind: 'object'; target: { [key: string]: JsonValue }; key: string }
|
||||
|
||||
type JsonWalkTask =
|
||||
| { kind: 'visit'; value: unknown; destination?: SnapshotDestination }
|
||||
| { kind: 'array-item'; source: unknown[]; index: number; target?: JsonValue[] }
|
||||
| { kind: 'object-property'; source: Record<string, unknown>; key: string; target?: { [key: string]: JsonValue } }
|
||||
| { kind: 'leave'; source: object }
|
||||
|
||||
/** Validate lossless JSON iteratively, optionally materializing a detached snapshot. */
|
||||
function walkJsonValue(value: unknown, detach: boolean): JsonValue | true | undefined {
|
||||
const ancestors = new Set<object>()
|
||||
let root: JsonValue | undefined
|
||||
const assign = (destination: SnapshotDestination | undefined, item: JsonValue): void => {
|
||||
if (destination === undefined) return
|
||||
if (destination.kind === 'root') {
|
||||
root = item
|
||||
} else if (destination.kind === 'array') {
|
||||
destination.target[destination.index] = item
|
||||
} else {
|
||||
Object.defineProperty(destination.target, destination.key, {
|
||||
value: item,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const tasks: JsonWalkTask[] = [{
|
||||
kind: 'visit',
|
||||
value,
|
||||
...(detach ? { destination: { kind: 'root' } as const } : {}),
|
||||
}]
|
||||
for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
|
||||
if (task.kind === 'leave') {
|
||||
ancestors.delete(task.source)
|
||||
continue
|
||||
}
|
||||
if (task.kind === 'array-item') {
|
||||
if (!Object.prototype.hasOwnProperty.call(task.source, task.index)) return undefined
|
||||
tasks.push({
|
||||
kind: 'visit',
|
||||
value: task.source[task.index],
|
||||
...(task.target === undefined ? {} : { destination: { kind: 'array', target: task.target, index: task.index } as const }),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (task.kind === 'object-property') {
|
||||
tasks.push({
|
||||
kind: 'visit',
|
||||
value: task.source[task.key],
|
||||
...(task.target === undefined ? {} : { destination: { kind: 'object', target: task.target, key: task.key } as const }),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const current = task.value
|
||||
if (current === null) {
|
||||
assign(task.destination, null)
|
||||
continue
|
||||
}
|
||||
if (typeof current === 'boolean' || typeof current === 'string') {
|
||||
assign(task.destination, current)
|
||||
continue
|
||||
}
|
||||
if (typeof current === 'number') {
|
||||
if (!Number.isFinite(current) || Object.is(current, -0)) return undefined
|
||||
assign(task.destination, current)
|
||||
continue
|
||||
}
|
||||
if (typeof current !== 'object') return undefined
|
||||
if (ancestors.has(current)) return undefined
|
||||
|
||||
if (Array.isArray(current)) {
|
||||
if (!hasPlainArrayPrototype(current)) return undefined
|
||||
const length = current.length
|
||||
if (Reflect.ownKeys(current).length !== length + 1) return undefined
|
||||
const target = detach ? [] as JsonValue[] : undefined
|
||||
if (target !== undefined) assign(task.destination, target)
|
||||
ancestors.add(current)
|
||||
tasks.push({ kind: 'leave', source: current })
|
||||
for (let index = length - 1; index >= 0; index--) {
|
||||
tasks.push({ kind: 'array-item', source: current, index, ...(target === undefined ? {} : { target }) })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (!hasPlainObjectPrototype(current)) return undefined
|
||||
const keys = enumerableStringKeys(current)
|
||||
if (keys === undefined) return undefined
|
||||
const target = detach ? {} as { [key: string]: JsonValue } : undefined
|
||||
if (target !== undefined) assign(task.destination, target)
|
||||
ancestors.add(current)
|
||||
tasks.push({ kind: 'leave', source: current })
|
||||
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) return undefined
|
||||
tasks.push({ kind: 'object-property', source: current as Record<string, unknown>, key, ...(target === undefined ? {} : { target }) })
|
||||
}
|
||||
}
|
||||
return detach ? root : true
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and detach lossless JSON in one read per property, so a stateful
|
||||
* getter cannot change between validation and copying. Accepts ordinary arrays,
|
||||
* plain or null-prototype objects, and JSON scalars; rejects sparse, cyclic,
|
||||
* exotic, negative-zero, and non-finite values. Getter throws propagate.
|
||||
* getter cannot change between validation and copying. Traversal is iterative,
|
||||
* so valid nesting is bounded by available memory rather than the JavaScript
|
||||
* call stack. Accepts ordinary arrays, plain or null-prototype objects, and JSON
|
||||
* scalars; rejects sparse, cyclic, exotic, negative-zero, and non-finite values.
|
||||
* Getter throws propagate.
|
||||
*
|
||||
* @param value - the candidate value to validate and detach.
|
||||
* @returns the detached snapshot, or `undefined` when the value is not
|
||||
* losslessly JSON-serializable.
|
||||
*/
|
||||
export function snapshotJsonValue<T>(value: T): T | undefined {
|
||||
const ancestors = new Set<object>()
|
||||
|
||||
const visit = (current: unknown): JsonValue | undefined => {
|
||||
if (current === null) return null
|
||||
switch (typeof current) {
|
||||
case 'boolean':
|
||||
case 'string':
|
||||
return current
|
||||
case 'number':
|
||||
return Number.isFinite(current) && !Object.is(current, -0) ? current : undefined
|
||||
case 'bigint':
|
||||
case 'function':
|
||||
case 'symbol':
|
||||
case 'undefined':
|
||||
return undefined
|
||||
case 'object':
|
||||
break
|
||||
}
|
||||
|
||||
if (ancestors.has(current)) return undefined
|
||||
ancestors.add(current)
|
||||
try {
|
||||
if (Array.isArray(current)) {
|
||||
if (Object.getPrototypeOf(current) !== Array.prototype) return undefined
|
||||
const length = current.length
|
||||
const snapshot: JsonValue[] = []
|
||||
for (let index = 0; index < length; index++) {
|
||||
if (!Object.prototype.hasOwnProperty.call(current, index)) return undefined
|
||||
const item = visit(current[index])
|
||||
if (item === undefined) return undefined
|
||||
snapshot.push(item)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(current) as unknown
|
||||
if (prototype !== Object.prototype && prototype !== null) return undefined
|
||||
const snapshot: { [key: string]: JsonValue } = {}
|
||||
for (const key of Object.keys(current)) {
|
||||
const item = visit((current as Record<string, unknown>)[key])
|
||||
if (item === undefined) return undefined
|
||||
// Define the key as data so a JSON field literally named "__proto__"
|
||||
// cannot mutate the snapshot's prototype through ordinary assignment.
|
||||
Object.defineProperty(snapshot, key, {
|
||||
value: item,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
}
|
||||
return snapshot
|
||||
} finally {
|
||||
ancestors.delete(current)
|
||||
}
|
||||
}
|
||||
|
||||
return visit(value) as T | undefined
|
||||
return walkJsonValue(value, true) as T | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,45 +183,8 @@ export function snapshotJsonValue<T>(value: T): T | undefined {
|
||||
* detaching it. Only own enumerable string properties participate; `toJSON`
|
||||
* is ignored and getters run, so persistence boundaries use the snapshotter.
|
||||
* @param value - the candidate event data to test.
|
||||
* @param seen - current recursion path; callers omit it.
|
||||
* @returns whether `value` survives JSON round-trip losslessly.
|
||||
*/
|
||||
export function isJsonValue(value: unknown, seen: Set<object> = new Set()): boolean {
|
||||
if (value === null) return true
|
||||
switch (typeof value) {
|
||||
case 'boolean':
|
||||
case 'string':
|
||||
return true
|
||||
case 'number':
|
||||
return Number.isFinite(value) && !Object.is(value, -0)
|
||||
case 'bigint':
|
||||
case 'function':
|
||||
case 'symbol':
|
||||
case 'undefined':
|
||||
return false
|
||||
case 'object':
|
||||
break // handled below
|
||||
}
|
||||
// object
|
||||
if (seen.has(value)) return false // circular
|
||||
seen.add(value)
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
if (Object.getPrototypeOf(value) !== Array.prototype) return false
|
||||
// Reject sparse arrays: a hole is skipped by `every`/`forEach` but
|
||||
// JSON.stringify writes it as `null`, so `[1, , 3]` would round-trip
|
||||
// lossily. Require every index 0..length-1 to be an OWN property.
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
if (!Object.prototype.hasOwnProperty.call(value, i)) return false
|
||||
if (!isJsonValue(value[i], seen)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
// Plain object only (reject Map/Set/Date/class instances).
|
||||
const proto = Object.getPrototypeOf(value) as unknown
|
||||
if (proto !== Object.prototype && proto !== null) return false
|
||||
return Object.values(value).every(v => isJsonValue(v, seen))
|
||||
} finally {
|
||||
seen.delete(value)
|
||||
}
|
||||
export function isJsonValue(value: unknown): boolean {
|
||||
return walkJsonValue(value, false) === true
|
||||
}
|
||||
|
||||
@@ -275,15 +275,25 @@ export interface SessionEventMap {
|
||||
*/
|
||||
'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string }
|
||||
/**
|
||||
* A completed tool call's model-facing result, plus an optional tool-private
|
||||
* `meta` presentation payload. `meta` is opaque to the core (`unknown` — the
|
||||
* producing tool owns its shape and reads it back in `presentResult`) but MUST
|
||||
* be JSON-serializable: `Session.append` runtime-validates all event data with
|
||||
* `isJsonValue`, so a non-serializable `meta` is rejected at the source, and the
|
||||
* durable log reproduces the identical card on replay. Absent unless the tool
|
||||
* attaches one (e.g. `dsh-tool-fs` carries its result-time contextual diff here).
|
||||
* A completed tool call's model-facing result, optional internal failure
|
||||
* identity, and optional tool-private `meta` presentation payload. `meta` is
|
||||
* opaque to the core (the producing tool owns its shape and reads it back in
|
||||
* `presentResult`) but MUST be JSON-serializable: `Session.append`
|
||||
* runtime-validates all event data with `isJsonValue`, so a non-serializable
|
||||
* `meta` is rejected at the source, and the durable log reproduces the
|
||||
* identical card on replay. Absent
|
||||
* unless the tool attaches one (e.g. `dsh-tool-fs` carries its result-time
|
||||
* contextual diff here).
|
||||
*/
|
||||
'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown }
|
||||
'tool/result': {
|
||||
turn: number
|
||||
step: number
|
||||
callId: CallId
|
||||
content: ContentBlock[]
|
||||
isError: boolean
|
||||
error?: { name: string; code: string }
|
||||
meta?: JsonValue
|
||||
}
|
||||
/** Steering content injected between steps of a running turn. */
|
||||
'steering/message': PromptMessageData & { turn: number }
|
||||
/** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isJsonValue, snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import { isJsonValue, snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-session'
|
||||
|
||||
function objectWithForgedIntrinsicPrototype(revoked = false): Record<string, unknown> {
|
||||
const prototype = Object.create(null) as Record<string, unknown>
|
||||
const ForgedObject = function ForgedObject(): void {}
|
||||
Object.defineProperty(ForgedObject, 'name', { value: 'Object' })
|
||||
ForgedObject.prototype = prototype
|
||||
const constructor = revoked ? Proxy.revocable(ForgedObject, {}) : undefined
|
||||
if (constructor !== undefined) constructor.revoke()
|
||||
Object.defineProperty(prototype, 'constructor', { value: constructor?.proxy ?? ForgedObject })
|
||||
return Object.assign(Object.create(prototype) as Record<string, unknown>, { value: 1 })
|
||||
}
|
||||
|
||||
describe('snapshotJsonValue', () => {
|
||||
it('copies the complete JSON scalar vocabulary and rejects unsupported scalars', () => {
|
||||
@@ -36,6 +48,22 @@ describe('snapshotJsonValue', () => {
|
||||
expect(Object.getPrototypeOf(snapshot.list[0])).toBe(Object.prototype)
|
||||
})
|
||||
|
||||
it('accepts intrinsic plain containers from another JavaScript realm', () => {
|
||||
const foreign = runInNewContext('({ object: { nested: [1] }, array: [2, { ok: true }] })') as {
|
||||
object: { nested: number[] }
|
||||
array: JsonValue[]
|
||||
}
|
||||
|
||||
expect(isJsonValue(foreign.object)).toBe(true)
|
||||
expect(isJsonValue(foreign.array)).toBe(true)
|
||||
const objectSnapshot = snapshotJsonValue(foreign.object)!
|
||||
const arraySnapshot = snapshotJsonValue(foreign.array)!
|
||||
expect(objectSnapshot).toEqual({ nested: [1] })
|
||||
expect(arraySnapshot).toEqual([2, { ok: true }])
|
||||
expect(Object.getPrototypeOf(objectSnapshot)).toBe(Object.prototype)
|
||||
expect(Object.getPrototypeOf(arraySnapshot)).toBe(Array.prototype)
|
||||
})
|
||||
|
||||
it('reads each object value and array slot once while materializing', () => {
|
||||
class Exotic {
|
||||
readonly accepted = false
|
||||
@@ -63,19 +91,64 @@ describe('snapshotJsonValue', () => {
|
||||
expect(arrayReads).toBe(1)
|
||||
})
|
||||
|
||||
it('rejects exotic containers, sparse arrays, cycles, and invalid children', () => {
|
||||
it('accepts deeply nested valid JSON without using the JavaScript call stack', () => {
|
||||
let value: JsonValue = 'leaf'
|
||||
for (let depth = 0; depth < 5_000; depth++) value = [value]
|
||||
|
||||
expect(isJsonValue(value)).toBe(true)
|
||||
let cursor: JsonValue | undefined = snapshotJsonValue(value)
|
||||
for (let depth = 0; depth < 5_000; depth++) {
|
||||
expect(Array.isArray(cursor)).toBe(true)
|
||||
cursor = Array.isArray(cursor) ? cursor[0] : undefined
|
||||
}
|
||||
expect(cursor).toBe('leaf')
|
||||
})
|
||||
|
||||
it('rejects exotic containers, sparse or decorated arrays, cycles, and invalid children', () => {
|
||||
class ExoticObject {
|
||||
readonly value = 1
|
||||
}
|
||||
class ExoticArray extends Array<number> {}
|
||||
const sparse = new Array<number>(1)
|
||||
const compensatedSparse = new Array<number>(1)
|
||||
Object.defineProperty(compensatedSparse, 'extra', { value: true })
|
||||
const decorated = [1]
|
||||
Object.defineProperty(decorated, 'extra', { value: true })
|
||||
const symbolDecorated = [1]
|
||||
Object.defineProperty(symbolDecorated, Symbol('extra'), { value: true })
|
||||
const hiddenObject = Object.defineProperty({}, 'hidden', { value: true })
|
||||
const symbolObject = { [Symbol('extra')]: true }
|
||||
const customPrototype = Object.create(null) as Record<string, unknown>
|
||||
const customPrototypeObject = Object.assign(Object.create(customPrototype) as Record<string, unknown>, { value: 1 })
|
||||
const forgedIntrinsicObject = objectWithForgedIntrinsicPrototype()
|
||||
const revokedIntrinsicObject = objectWithForgedIntrinsicPrototype(true)
|
||||
const forgedPrototype: unknown[] = []
|
||||
Object.setPrototypeOf(forgedPrototype, null)
|
||||
const forgedArray = [1]
|
||||
Object.setPrototypeOf(forgedArray, forgedPrototype)
|
||||
const cyclic: Record<string, unknown> = {}
|
||||
cyclic.self = cyclic
|
||||
const foreignExotics = runInNewContext(`(() => {
|
||||
class Box { constructor() { this.value = 1 } }
|
||||
class List extends Array {}
|
||||
return [new Box(), new List(1)]
|
||||
})()`) as [object, unknown[]]
|
||||
|
||||
expect(snapshotJsonValue(new ExoticObject())).toBeUndefined()
|
||||
expect(snapshotJsonValue(new Map([['value', 1]]))).toBeUndefined()
|
||||
expect(snapshotJsonValue(new ExoticArray(1))).toBeUndefined()
|
||||
expect(snapshotJsonValue(foreignExotics[0])).toBeUndefined()
|
||||
expect(snapshotJsonValue(foreignExotics[1])).toBeUndefined()
|
||||
expect(snapshotJsonValue(sparse)).toBeUndefined()
|
||||
expect(snapshotJsonValue(compensatedSparse)).toBeUndefined()
|
||||
expect(snapshotJsonValue(decorated)).toBeUndefined()
|
||||
expect(snapshotJsonValue(symbolDecorated)).toBeUndefined()
|
||||
expect(snapshotJsonValue(hiddenObject)).toBeUndefined()
|
||||
expect(snapshotJsonValue(symbolObject)).toBeUndefined()
|
||||
expect(snapshotJsonValue(customPrototypeObject)).toBeUndefined()
|
||||
expect(snapshotJsonValue(forgedIntrinsicObject)).toBeUndefined()
|
||||
expect(snapshotJsonValue(revokedIntrinsicObject)).toBeUndefined()
|
||||
expect(snapshotJsonValue(forgedArray)).toBeUndefined()
|
||||
expect(snapshotJsonValue(cyclic)).toBeUndefined()
|
||||
expect(snapshotJsonValue([undefined])).toBeUndefined()
|
||||
expect(snapshotJsonValue({ value: undefined })).toBeUndefined()
|
||||
@@ -133,16 +206,40 @@ describe('isJsonValue', () => {
|
||||
expect(isJsonValue(nullPrototype)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects sparse arrays, invalid children, exotic objects, and cycles', () => {
|
||||
it('rejects sparse or decorated arrays, invalid children, exotic objects, and cycles', () => {
|
||||
class Exotic {
|
||||
readonly value = 1
|
||||
}
|
||||
class ExoticArray extends Array<number> {}
|
||||
const sparse = new Array<number>(1)
|
||||
const compensatedSparse = new Array<number>(1)
|
||||
Object.defineProperty(compensatedSparse, 'extra', { value: true })
|
||||
const decorated = Object.assign([1], { extra: true })
|
||||
const symbolDecorated = [1]
|
||||
Object.defineProperty(symbolDecorated, Symbol('extra'), { value: true })
|
||||
const hiddenObject = Object.defineProperty({}, 'hidden', { value: true })
|
||||
const symbolObject = { [Symbol('extra')]: true }
|
||||
const customPrototype = Object.create(null) as Record<string, unknown>
|
||||
const customPrototypeObject = Object.assign(Object.create(customPrototype) as Record<string, unknown>, { value: 1 })
|
||||
const forgedIntrinsicObject = objectWithForgedIntrinsicPrototype()
|
||||
const revokedIntrinsicObject = objectWithForgedIntrinsicPrototype(true)
|
||||
const forgedPrototype: unknown[] = []
|
||||
Object.setPrototypeOf(forgedPrototype, null)
|
||||
const forgedArray = [1]
|
||||
Object.setPrototypeOf(forgedArray, forgedPrototype)
|
||||
const cyclic: Record<string, unknown> = {}
|
||||
cyclic.self = cyclic
|
||||
|
||||
expect(isJsonValue(sparse)).toBe(false)
|
||||
expect(isJsonValue(compensatedSparse)).toBe(false)
|
||||
expect(isJsonValue(decorated)).toBe(false)
|
||||
expect(isJsonValue(symbolDecorated)).toBe(false)
|
||||
expect(isJsonValue(hiddenObject)).toBe(false)
|
||||
expect(isJsonValue(symbolObject)).toBe(false)
|
||||
expect(isJsonValue(customPrototypeObject)).toBe(false)
|
||||
expect(isJsonValue(forgedIntrinsicObject)).toBe(false)
|
||||
expect(isJsonValue(revokedIntrinsicObject)).toBe(false)
|
||||
expect(isJsonValue(forgedArray)).toBe(false)
|
||||
expect(isJsonValue(new ExoticArray(1))).toBe(false)
|
||||
expect(isJsonValue([undefined])).toBe(false)
|
||||
expect(isJsonValue({ value: undefined })).toBe(false)
|
||||
|
||||
@@ -15,7 +15,7 @@ tools:
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. `timeoutMs`, when present, must be positive and finite. The optional synchronous `finalizeContent` callback is snapshotted when a call starts and may replace only final model-facing content after every pipeline outcome is normalized, including an error discovered while losslessly snapshotting another result field. Disposed with the calling fiber.
|
||||
- `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition with a mandatory canonical `output` declaration. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Missing or unsupported output declarations and a non-positive or non-finite `timeoutMs` fail at registration. The optional synchronous `finalizeContent` callback is snapshotted when a call starts and may replace only final model-facing content after every pipeline outcome is normalized, including an error discovered while materializing another result field. Disposed with the calling fiber.
|
||||
- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals).
|
||||
- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed.
|
||||
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md)).
|
||||
@@ -37,14 +37,14 @@ The live registry pipeline has three transformable waterfalls, then the definiti
|
||||
|
||||
### Key types
|
||||
|
||||
- `ToolDefinition` — `ToolSchema` + `execute(args, exec)`, whose async work must cooperatively stop through `exec.signal`, plus optional final-content and presentation callbacks, cooperative `timeoutMs`, and optional per-call `isConcurrencySafe(args)` classification. `finalizeContent(exec, result)` runs exactly once for every normalized result, including failures that bypass post-policy, and can replace only `content`; it must be synchronous and total.
|
||||
- `ToolDefinition` — `ToolSchema` + mandatory `output { schema, render, presentationMeta? }` + `execute(args, exec)`, optional final-content and presentation callbacks, cooperative `timeoutMs`, and optional per-call `isConcurrencySafe(args)` classification. A body returns only the canonical JSON value declared by the output schema and cooperatively stops through `exec.signal`. `finalizeContent(exec, result)` runs exactly once for every normalized result, including failures that bypass post-policy, and can replace only `content`; it must be synchronous and total.
|
||||
- `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, signal, agent?, parent? }`; `signal` is required and readonly, callers may pass an enclosing execution's opaque token as `parent`, and callers never choose the new execution's own token.
|
||||
- `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary.
|
||||
- `ToolExecution` — the readonly pipeline view: immutable `{ token, callId, name, arguments, signal, agent?, parent? }`; the registry separately retains and re-fuses the original caller signal. `ToolDispatchExecution` is the `tools/execute`-only view whose required signal is mutable, so a wrapper may replace and restore it but cannot delete it. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
|
||||
- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws or cancellation wins; it never injects immediately.
|
||||
- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ content, isError, error?, additionalContexts?, meta? }`. Call identity stays on the immutable `ToolExecution` supplied alongside the result instead of being duplicated on the outcome. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text. `additionalContexts` preserves each deferred or post-execute `HookContext` with its own source and durable JSON metadata; the loop buffers the array and appends each entry as a `context/message` after all `tool/result`s in the step.
|
||||
- `ToolExecutionResult` — discriminated execution-local outcome. Success is `{ isError:false, value:JsonValue, content, meta?, additionalContexts? }`; failure is `{ isError:true, error:{ message, info? }, content, meta?, additionalContexts? }` and has no value. Call identity stays on the immutable `ToolExecution`. The registry snapshots, validates, and freezes the canonical value before rendering, then materializes the durable presentation fields before final observation. `ToolFailure.info` carries an internal `{ name, code }` for a `HarnessError`; `additionalContexts` preserves every deferred or post-execute `HookContext` for the loop's post-result FIFO.
|
||||
- `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny.
|
||||
- `PostToolDecision` — `{kind:'accept', content?, additionalContexts?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContexts?}` (turn it into an `isError` whose content is the corrective feedback). Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision.
|
||||
- `PostToolDecision` — accept may replace `content` or `value`, never both, and may attach `additionalContexts`; block turns feedback into a valueless failure. Content replacement preserves the canonical value and metadata. Value replacement is revalidated and rerenders content/metadata. Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision.
|
||||
- `ToolGuard` — `(execution) => string | undefined`; the returned string is a final monotonic denial reason evaluated after the reorderable pre-execute waterfall and before dispatch.
|
||||
- `ToolCallView` / `ToolResultView` — provider-neutral `card`-tagged render intents a tool returns from `presentCall` / `presentResult` to own how a UI renders ITS calls (see "Tool-owned UI presentation").
|
||||
|
||||
@@ -52,8 +52,8 @@ The live registry pipeline has three transformable waterfalls, then the definiti
|
||||
|
||||
- Tool plugins call `ctx.tools.register()` — schemas flow into the assembly automatically.
|
||||
- `tools/pre-execute` is the reorderable allow/deny/ask gate; `ctx.tools.guard()` adds monotonic owner policy after it.
|
||||
- `tools/execute` wraps normalized core dispatch for timeout, retry, or metrics. Wrappers may replace only the operational signal.
|
||||
- `tools/post-execute` may replace content, block with feedback, or attach ordered contexts. A definition's optional `finalizeContent` then owns its last content-only invariant across normal results and outer pipeline failures; `tools/result` observes the immutable final outcome.
|
||||
- `tools/execute` wraps normalized canonical dispatch for timeout, retry, or metrics. Wrappers may replace only the operational signal; a wrapper-authored success is normalized through the resolved tool's output declaration. Canonical-result provenance belongs to one immutable dispatch token, so a cached result from another call or tool is revalidated under the active declaration.
|
||||
- `tools/post-execute` may replace presentation content, replace the canonical value, block with feedback, or attach ordered contexts. A definition's optional `finalizeContent` then owns its last content-only invariant across normal results and outer pipeline failures; `tools/result` observes the immutable final outcome. Content replacement is not a confidentiality boundary: block or replace the value when programmatic consumers must not receive it.
|
||||
- Exact signatures and ordering live in the generated [event catalog](../../../docs/cordis-catalog/events.md) and [pipeline](../../../docs/tool-execution-pipeline.md).
|
||||
- MCP servers: one plugin per server, discover tools, call `ctx.tools.register()` with the server's schemas.
|
||||
|
||||
@@ -76,27 +76,30 @@ ctx.tools.register(defineTool({
|
||||
offset: { type: 'number' },
|
||||
limit: { type: 'number' },
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args, value) => [{ type: 'text', text: value }],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
// args is typed: { path: string; offset?: number; limit?: number }
|
||||
const text = await readFile(args.path, { encoding: 'utf8', signal: exec.signal })
|
||||
return [{ type: 'text', text }]
|
||||
return readFile(args.path, { encoding: 'utf8', signal: exec.signal })
|
||||
},
|
||||
}))
|
||||
```
|
||||
|
||||
The helper converts the author-facing `SchemaSpec` (with `required: true` as a per-property boolean) to standard JSON Schema for the wire format and uses the same typed spec for execute/presentation validation. Raw JSON-Schema tool definitions (from MCP servers) are still accepted by the registry directly.
|
||||
The unified schema DSL uses `ParameterSchemaSpec` for the implicit open parameter object and `ValueSchemaSpec` for any JSON-value root. It supports `string`, `number`, `integer`, `boolean`, `null`, `array`, `object`, author-only `json`, and exact-one `oneOf`; scalar `enum`/`const` values are type-correct. Every explicit DSL object declares `additionalProperties: true | false`, while the implicit parameter root and raw JSON Schema keep the standard open default. Schema records accept only own enumerable string keys, and schema arrays must be dense ordinary arrays. Compilation, validation, registry detachment, and schema-to-TypeScript rendering use explicit work stacks, so runtime processing of valid deep schemas is memory-bounded rather than call-stack-bounded; `InferValue` preserves exact types through 16 container levels and then falls back to `JsonValue` so TypeScript itself remains stack-safe.
|
||||
|
||||
A `defineTool` definition validates model arguments before execution and turns missing required values, wrong primitives, invalid enum members, and nested violations into `ToolArgsError` (`INVALID_ARGS`) for the normal error-result path. Extra keys are allowed, defaults are not applied, and object or array fields without `properties` or `items` receive only a type check. Raw-registered tools own their validation.
|
||||
A `defineTool` definition validates model arguments before execution and turns missing required values, wrong primitives, invalid enum members, and nested violations into `ToolArgsError` (`INVALID_ARGS`) for the normal error-result path. It also infers the body return and pure output projectors from `output.schema`; the registry snapshots and validates the returned lossless JSON before presentation. The implicit parameter root is open; an explicit object accepts extra keys only with `additionalProperties: true`, and a closed object with no declared properties accepts only `{}`. Raw JSON Schema objects remain open unless they explicitly set `additionalProperties: false`. Defaults are not applied; open objects without `properties` and arrays without `items` receive only a container type check. Raw-registered tools own input validation but still declare and receive registry-enforced output.
|
||||
|
||||
See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, and `schemaSpecToJsonSchema` in the public API for details.
|
||||
See `defineTool`, `validateArgs`, `ToolArgsError`, `ValueSchemaSpec`, `ParameterSchemaSpec`, `InferValue`, `InferArgs`, `valueSchemaSpecToJsonSchema`, and `parameterSchemaSpecToJsonSchema` in the public API for details.
|
||||
|
||||
Optional `timeoutMs` must be positive and finite; it is policy metadata, not model-visible schema.
|
||||
|
||||
Optional `isConcurrencySafe(args)` receives typed, softly validated arguments. Exact `true` permits concurrent dispatch/body execution; invalid input and all other outcomes remain exclusive. Opted-in bodies do not mutate parent-owned state, and shared-state races must commute or fail closed. The [parallel tool-call Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md) owns the full safety contract.
|
||||
|
||||
### Structured-output schema subset
|
||||
### Enforced raw JSON Schema subset
|
||||
|
||||
`StructuredOutputSchema` is the object-rooted raw JSON Schema subset used by subagents and workflows for machine-readable results. It accepts one scalar `type`, object `properties`/`required`/boolean `additionalProperties`, array `items`, and scalar `enum`/`const`. The annotations `description`, `title`, `default`, and `examples` are ignored but must remain JSON data. Type arrays, undeclared required keys, and unsupported keywords fail through `OutputSchemaError` rather than being ignored; `validateStructuredValue()` returns path-qualified violations without throwing.
|
||||
`JsonSchemaNode` is the raw counterpart shared by tool outputs, Code Mode generation, subagents, and workflows. It permits any JSON root, an annotation-only unconstrained JSON node, and exact-one `oneOf`; annotations must remain lossless JSON. `assertSupportedJsonSchema()` rejects unsupported constructs, while `validateJsonSchemaValue()` returns path-qualified violations. Subagents and workflows retain their caller-defined object-root requirement through `assertObjectJsonSchema()` and `ObjectJsonSchema`, not through a limitation in the shared vocabulary.
|
||||
|
||||
### Tool-owned UI presentation
|
||||
|
||||
@@ -105,15 +108,16 @@ Tools optionally own pure `presentCall()` and `presentResult()` render intents,
|
||||
- Call views are `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`, `{ card: 'terminal', title, description?, cwd? }`, or `{ card: 'diff', title, diffs, locations? }`.
|
||||
- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, or `{ card: 'diff', title?, diffs }`.
|
||||
|
||||
Returning `undefined` selects generic fallback. Presenters depend only on their arguments because UIs call them during live streaming and log replay. Result presentation may read JSON-serializable `result.meta`, which persists with the result; `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns the rationale.
|
||||
Returning `undefined` selects generic fallback. Presenters depend only on their arguments and the durable result because UIs call them during live streaming and log replay. `output.presentationMeta(args, value)` derives JSON metadata for direct surface calls; that metadata persists with `tool/result` and returns to `presentResult`, while the canonical value itself remains execution-local and is never replayed. Nested Code dispatches do not compute metadata. `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [canonical-output Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/presentation split and the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns card vocabulary.
|
||||
|
||||
### Code Mode
|
||||
|
||||
Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic TypeScript SDK for the current scope; only program output re-enters model context. Each JSON-normalized binding re-enters the complete tool pipeline sequentially with logged correlation to the outer call. Denials reject that binding, ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; failures surface as `CodeRunFailedError`. See the [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`.
|
||||
Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic TypeScript SDK for the current scope; only the program's outer logs and return value re-enter model context. The SDK declares exact `ToolArgsMap` and `ToolOutputMap` entries for every visible tool, and each binding resolves to the tool's canonical JSON value. Each lossless-JSON binding call re-enters the complete tool pipeline sequentially with logged correlation to the outer call. Denials and other failed results reject with the real program-visible `ToolCallError` carrying only `toolName` and `message`; Native content and internal error codes stay outside the Code contract. Ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; runtime failures surface as `CodeRunFailedError`. See the [Code Mode foundation](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md), [typed-return contract](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md), and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`.
|
||||
|
||||
- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, a `declare const tools: {...}` TypeScript declaration of the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) is total: constructs outside the `defineTool` subset degrade to `unknown`, never throw.
|
||||
- **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized before dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes underlying calls one at a time in submission order), given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A denial reaches the program as a binding rejection, and each sub-call is logged as a `tool/code-dispatch` session event with deterministic id `<parent>:code:<n>`; `deriveMessages()` does not surface that event. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/meta even when the program later fails.
|
||||
- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, `JsonValue`, exact `ToolArgsMap` / `ToolOutputMap`, `ToolName`, the `ToolCallError` declaration, and a mapped `tools` namespace for the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) handles every unified schema construct and degrades unsupported raw constructs to `unknown`, never throwing during prompt assembly.
|
||||
- **The dispatch bridge** (`run_code`'s execute): every binding call is snapshotted as lossless JSON before dispatch (`undefined`, `BigInt`, cycles, sparse arrays, `-0`, and exotic objects reject that one call), serialized through a per-run queue (even `Promise.all` executes underlying calls one at a time in submission order), given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A success returns the final canonical value after policy; a failure reaches the worker as one message and becomes `ToolCallError(toolName, message)`. Each sub-call is logged as a `tool/code-dispatch` session event with deterministic id `<parent>:code:<n>` and a bounded Native-content summary; `deriveMessages()` does not surface that event or persist the value. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/meta even when the program later fails.
|
||||
- **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from.
|
||||
- **Result boundary**: intermediate binding values cross the worker boundary whole and have no per-binding byte cap. `run_code` returns canonical `{ logs: string[], result?: JsonValue }`; strings render raw, every other present JSON root renders through a stack-safe pretty JSON traversal whose total indentation is capped at ten characters (deeper subtrees stay compact), `null` remains explicit, and absent `result` means the program returned `undefined`. The worker's configurable `maxOutputBytes` (default 64 MiB) applies only to the combined serialized outer log-array, completion-value, or failure-message payloads; fixed result-envelope syntax and presentation whitespace are outside that ledger. Invalid and over-limit completions fail explicitly, and only this outer result is eligible for ordinary spill.
|
||||
|
||||
### Parallel execution
|
||||
|
||||
@@ -148,8 +152,8 @@ Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.m
|
||||
|
||||
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.
|
||||
|
||||
@@ -182,8 +186,8 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
- **Concurrency policy is not an event seam** — `executionMode()` reads the resolved tool definition directly; plugins can only declare a classifier on definitions they own.
|
||||
- **`tools/pre-execute` deliberately cannot rewrite `exec.arguments`** — logged and rendered args would desync from what ran; the rewrite design is [a proposed Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md).
|
||||
- **`defineTool`'s schema DSL is a deliberate subset** — string/number/boolean/object/array with string-only `enum`; `validateArgs` tolerates extra keys and preserves `default` as a model-visible JSON Schema annotation without applying it during validation; dynamic Cordis mounts may supply defaults even though first-party definitions do not, while raw-registered JSON-Schema tools validate their own input.
|
||||
- **Caller-defined subagent and workflow structured outputs remain object-rooted** — this is a consumer-level guard; the shared schema vocabulary and tool outputs support every JSON root.
|
||||
- **`timeoutMs` on a definition is declarative only** — the registry never enforces deadlines; enforcement requires the `@deepseek-ai/dsh-timeout-policy` wrapper.
|
||||
- **Code Mode is TypeScript-only and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language === 'typescript'`; scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only.
|
||||
- **Code Mode bindings return text only** — non-text content blocks in a sub-call result collapse to `[<type> content]` placeholders.
|
||||
- **Code Mode intermediate values are execution-local and unbounded by bytes** — they cannot be reconstructed from session replay and may exhaust process or worker memory; only the outer `run_code` output has the worker's configurable hard cap.
|
||||
- **`run_code` state is fresh per run** — a persistent REPL-style kernel is rejected for the MVP (cross-call state would be invisible to the log); see [the Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md).
|
||||
|
||||
@@ -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\`\`\``
|
||||
}
|
||||
|
||||
@@ -6,11 +6,11 @@ import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, TOOL_ABORTED_BEFORE_DISPATCH, defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { Config, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, TOOL_ABORTED_BEFORE_DISPATCH, defineContentToolFixture, defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { Config, JsonSchemaNode, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEventMap } from '@deepseek-ai/dsh-session'
|
||||
import type { JsonValue, SessionEventMap } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
@@ -74,9 +74,13 @@ function registerEcho(ctx: Context, name = 'echo'): unknown[] {
|
||||
name,
|
||||
description: `Echo tool ${name}.`,
|
||||
parameters: { value: { type: 'string', required: true } },
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args, value) => [{ type: 'text', text: value }],
|
||||
},
|
||||
execute(args) {
|
||||
calls.push(args)
|
||||
return Promise.resolve([{ type: 'text' as const, text: `${name}:${args.value}` }])
|
||||
return Promise.resolve(`${name}:${args.value}`)
|
||||
},
|
||||
}))
|
||||
return calls
|
||||
@@ -122,8 +126,32 @@ describe('mode-aware wire contribution', () => {
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME])
|
||||
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')
|
||||
expect(sdk?.text).toContain('declare const tools: {')
|
||||
expect(sdk?.text).toContain('echo(args:')
|
||||
expect(sdk?.text).not.toContain('run_code(args:')
|
||||
expect(sdk?.text).toContain('echo: {')
|
||||
expect(sdk?.text).not.toContain('run_code:')
|
||||
})
|
||||
|
||||
it('projects deeply nested output schemas into the Code Mode SDK without structured-clone recursion', async () => {
|
||||
const { ctx, systemPrompt } = await setup({ mode: 'code' })
|
||||
let output: JsonSchemaNode = { type: 'string' }
|
||||
for (let depth = 0; depth < 5_000; depth++) {
|
||||
output = { oneOf: [output, { type: 'null' }] }
|
||||
}
|
||||
ctx.tools.register({
|
||||
name: 'deep_output',
|
||||
description: 'Return a deeply nested output union.',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
output: {
|
||||
schema: output,
|
||||
render: (_args, value) => [{ type: 'text', text: typeof value === 'string' ? value : 'null' }],
|
||||
},
|
||||
execute() { return Promise.resolve('ok') },
|
||||
})
|
||||
|
||||
const assembly = await systemPrompt.assemble()
|
||||
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text
|
||||
|
||||
expect(sdk).toContain('deep_output: Record<string, JsonValue>;')
|
||||
expect(sdk).toContain('deep_output: string | null')
|
||||
})
|
||||
|
||||
it.each(['code', 'both'] as const)('treats expert assembly output as authoritative in mode %s', async (mode) => {
|
||||
@@ -175,8 +203,8 @@ describe('mode-aware wire contribution', () => {
|
||||
? [RUN_CODE_NAME]
|
||||
: ['echo', RUN_CODE_NAME])
|
||||
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text
|
||||
expect(sdk).toContain('echo(args:')
|
||||
expect(sdk).not.toContain('hidden(args:')
|
||||
expect(sdk).toContain('echo: {')
|
||||
expect(sdk).not.toContain('hidden:')
|
||||
|
||||
runtime.behavior = request => Promise.resolve({
|
||||
logs: [],
|
||||
@@ -205,8 +233,8 @@ describe('mode-aware wire contribution', () => {
|
||||
? [RUN_CODE_NAME]
|
||||
: ['kept', RUN_CODE_NAME])
|
||||
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text
|
||||
expect(sdk).not.toContain('denied(args:')
|
||||
expect(sdk).toContain('kept(args:')
|
||||
expect(sdk).not.toContain('denied:')
|
||||
expect(sdk).toContain('kept: {')
|
||||
|
||||
runtime.behavior = request => Promise.resolve({
|
||||
logs: [],
|
||||
@@ -220,7 +248,7 @@ describe('mode-aware wire contribution', () => {
|
||||
it.each(['code', 'both'] as const)('reserves run_code against scoped shadows and explicit restrictions in mode %s', async (mode) => {
|
||||
const { ctx, systemPrompt } = await setup({ mode })
|
||||
const { scope, agent } = await mintAgentScope(ctx)
|
||||
const impostor = defineTool({
|
||||
const impostor = defineContentToolFixture({
|
||||
name: RUN_CODE_NAME,
|
||||
description: 'Scoped impostor.',
|
||||
parameters: {},
|
||||
@@ -232,7 +260,7 @@ describe('mode-aware wire contribution', () => {
|
||||
expect(() => scope.ctx.tools.restrict({ allow: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/)
|
||||
expect(() => scope.ctx.tools.restrict({ deny: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/)
|
||||
scope.ctx.systemPrompt.section({ name: 'scoped-note', order: 149, text: 'safe note' })
|
||||
scope.ctx.tools.register(defineTool({
|
||||
scope.ctx.tools.register(defineContentToolFixture({
|
||||
name: 'scoped_safe',
|
||||
description: 'Safe scoped tool.',
|
||||
parameters: {},
|
||||
@@ -244,7 +272,7 @@ describe('mode-aware wire contribution', () => {
|
||||
expect(transports).toHaveLength(1)
|
||||
expect(transports[0]?.description).toContain('Execute a TypeScript program')
|
||||
expect(assembly.sections.find(section => section.name === 'scoped-note')?.text).toBe('safe note')
|
||||
expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('scoped_safe(args:')
|
||||
expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('scoped_safe:')
|
||||
expect(ctx.tools.get(RUN_CODE_NAME, agent)).toBe(ctx.tools.get(RUN_CODE_NAME))
|
||||
const result = await runCode(ctx, 'return 1', { agent })
|
||||
expect(result.content).toEqual([{ type: 'text', text: '(run_code completed with no output)' }])
|
||||
@@ -268,6 +296,10 @@ describe('mode-aware wire contribution', () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'both' })
|
||||
registerEcho(ctx)
|
||||
runtime.behavior = (request) => {
|
||||
expect(request.bindings[0]!.errorClass).toEqual({
|
||||
name: 'ToolCallError',
|
||||
memberNameProperty: 'toolName',
|
||||
})
|
||||
const functions = request.bindings[0]!.functions
|
||||
return Promise.resolve({
|
||||
logs: [],
|
||||
@@ -331,10 +363,13 @@ describe('the run_code dispatch bridge', () => {
|
||||
const tools = request.bindings[0]!.functions
|
||||
const first = await tools.echo!({ value: 'one' })
|
||||
const second = await tools.echo!({ value: 'two' })
|
||||
return { logs: [`saw ${String(first)}`], value: second }
|
||||
if (typeof first !== 'string' || typeof second !== 'string') throw new Error('echo returned a non-string')
|
||||
return { logs: [`saw ${first}`], value: second }
|
||||
}
|
||||
const result = await runCode(ctx, 'const …: string = …', { agent })
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected run_code success')
|
||||
expect(result.value).toEqual({ logs: ['saw echo:one'], result: 'echo:two' })
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'saw echo:one\necho:two' }])
|
||||
expect(calls).toEqual([{ value: 'one' }, { value: 'two' }])
|
||||
const dispatches = events.filter(event => event.type === 'tool/code-dispatch')
|
||||
@@ -342,7 +377,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
{ parentCallId: 'call-1', subCallId: 'call-1:code:1', name: 'echo', arguments: { value: 'one' }, isError: false, resultSummary: 'echo:one' },
|
||||
{ parentCallId: 'call-1', subCallId: 'call-1:code:2', name: 'echo', arguments: { value: 'two' }, isError: false, resultSummary: 'echo:two' },
|
||||
])
|
||||
expect(result.meta).toEqual({ logs: ['saw echo:one'] })
|
||||
expect(result.meta).toBeUndefined()
|
||||
})
|
||||
|
||||
it('exposes only an opaque parent token to nested result observers', async () => {
|
||||
@@ -380,6 +415,10 @@ describe('the run_code dispatch bridge', () => {
|
||||
name: 'probe',
|
||||
description: 'Records execution overlap.',
|
||||
parameters: { id: { type: 'string', required: true } },
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args, value) => [{ type: 'text', text: value }],
|
||||
},
|
||||
async execute(args) {
|
||||
active++
|
||||
expect(active, 'probe executions overlapped').toBe(1)
|
||||
@@ -387,12 +426,13 @@ describe('the run_code dispatch bridge', () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
intervals.push(['exit', args.id])
|
||||
active--
|
||||
return [{ type: 'text' as const, text: args.id }]
|
||||
return args.id
|
||||
},
|
||||
}))
|
||||
runtime.behavior = async (request) => {
|
||||
const tools = request.bindings[0]!.functions
|
||||
const values = await Promise.all([tools.probe!({ id: 'a' }), tools.probe!({ id: 'b' }), tools.probe!({ id: 'c' })])
|
||||
if (!values.every(value => typeof value === 'string')) throw new Error('probe returned a non-string')
|
||||
return { logs: [], value: values.join(',') }
|
||||
}
|
||||
const result = await runCode(ctx, 'program')
|
||||
@@ -407,7 +447,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
|
||||
it('rejects the program-side call when the tool errors, with the tool error text', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'fail',
|
||||
description: 'Always fails.',
|
||||
parameters: {},
|
||||
@@ -422,7 +462,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
}
|
||||
}
|
||||
const result = await runCode(ctx, 'program')
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'caught: Error: deliberate failure' })
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'caught: deliberate failure' })
|
||||
})
|
||||
|
||||
it('a tools/pre-execute deny reaches the program as a binding rejection', async () => {
|
||||
@@ -445,7 +485,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
expect((result.content[0] as { text: string }).text).toContain('not on my watch')
|
||||
})
|
||||
|
||||
it('rejects a binding argument that does not survive JSON normalization, dispatching nothing', async () => {
|
||||
it('rejects a binding argument that is not lossless JSON, dispatching nothing', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
const calls = registerEcho(ctx)
|
||||
const { agent, events } = fakeAgent()
|
||||
@@ -458,25 +498,24 @@ describe('the run_code dispatch bridge', () => {
|
||||
}
|
||||
}
|
||||
const result = await runCode(ctx, 'program', { agent })
|
||||
expect((result.content[0] as { text: string }).text).toContain('JSON-serializable')
|
||||
expect((result.content[0] as { text: string }).text).toContain('lossless JSON')
|
||||
expect(calls).toEqual([])
|
||||
expect(events.filter(event => event.type === 'tool/code-dispatch')).toEqual([])
|
||||
})
|
||||
|
||||
it('dispatches the JSON-normalized value: what the tool sees is what the event logs', async () => {
|
||||
it('dispatches and logs independent snapshots of the same lossless JSON value', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
const calls = registerEcho(ctx)
|
||||
const { agent, events } = fakeAgent()
|
||||
runtime.behavior = async (request) => {
|
||||
// A Date survives structured clone but is not JSON; the bridge
|
||||
// normalizes it to its JSON form (an ISO string) BEFORE dispatch.
|
||||
await request.bindings[0]!.functions.echo!({ value: 'x', when: new Date(0) }).catch(() => undefined)
|
||||
const args = Object.assign(Object.create(null) as Record<string, unknown>, { value: 'x', nested: ['same'] })
|
||||
await request.bindings[0]!.functions.echo!(args)
|
||||
return { logs: [] }
|
||||
}
|
||||
await runCode(ctx, 'program', { agent })
|
||||
expect(calls).toEqual([{ value: 'x', when: '1970-01-01T00:00:00.000Z' }])
|
||||
expect(calls).toEqual([{ value: 'x', nested: ['same'] }])
|
||||
const dispatch = events.find(event => event.type === 'tool/code-dispatch')?.data as SessionEventMap['tool/code-dispatch']
|
||||
expect(dispatch.arguments).toEqual({ value: 'x', when: '1970-01-01T00:00:00.000Z' })
|
||||
expect(dispatch.arguments).toEqual({ value: 'x', nested: ['same'] })
|
||||
})
|
||||
|
||||
it('defers sub-call additionalContexts onto the outer run_code result', async () => {
|
||||
@@ -551,7 +590,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
})
|
||||
const result = await runCode(ctx, 'program')
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toEqual({ name: 'CodeRunFailedError', code: 'CODE_RUN_FAILED' })
|
||||
expect(result.error).toMatchObject({ info: { name: 'CodeRunFailedError', code: 'CODE_RUN_FAILED' } })
|
||||
const text = (result.content[0] as { text: string }).text
|
||||
expect(text).toContain('code run failed (timeout)')
|
||||
expect(text).toContain('compute budget exhausted')
|
||||
@@ -568,7 +607,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
const seen: string[] = []
|
||||
let sawAbort = false
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'slow',
|
||||
description: 'Slow tool observing its signal.',
|
||||
parameters: { id: { type: 'string', required: true } },
|
||||
@@ -604,7 +643,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
let sawAbort = false
|
||||
let started!: () => void
|
||||
const inFlight = new Promise<void>((resolve) => { started = resolve })
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'slow',
|
||||
description: 'Slow tool observing its signal.',
|
||||
parameters: { id: { type: 'string', required: true } },
|
||||
@@ -654,7 +693,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
expect((result.content[0] as { text: string }).text).toContain('requires a code runtime')
|
||||
})
|
||||
|
||||
it('presents the PROGRAM as the execute-card title on both call and result (the one slot execute cards always show)', async () => {
|
||||
it('presents the program as the execute-card title', async () => {
|
||||
const { ctx } = await setup({ mode: 'code' })
|
||||
const tool = ctx.tools.get(RUN_CODE_NAME)!
|
||||
// The program IS the title, mirroring how command tools title their cards
|
||||
@@ -667,24 +706,59 @@ describe('the run_code dispatch bridge', () => {
|
||||
kind: 'execute',
|
||||
rawInput: 'return 1',
|
||||
})
|
||||
const view = tool.presentResult?.({ code: 'return 1' }, {
|
||||
content: [{ type: 'text', text: 'model-facing' }],
|
||||
isError: false,
|
||||
meta: { logs: ['printed'] },
|
||||
})
|
||||
|
||||
it.each([
|
||||
['logs only', { logs: ['printed'] }, 'printed'],
|
||||
['result only', { logs: [], value: 'returned' }, 'returned'],
|
||||
['logs plus result', { logs: ['printed'], value: 'returned' }, 'printed\nreturned'],
|
||||
['no output', { logs: [] }, '(run_code completed with no output)'],
|
||||
] as [string, CodeRunResult, string][])('keeps %s in durable content without a result presenter', async (_name, output, text) => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
runtime.behavior = () => Promise.resolve(output)
|
||||
|
||||
const result = await runCode(ctx, 'return 1')
|
||||
const tool = ctx.tools.get(RUN_CODE_NAME)!
|
||||
|
||||
expect(result.content).toEqual([{ type: 'text', text }])
|
||||
// Surfaces keep the pending program title and render this durable content
|
||||
// through their generic fallback. Omitting a result view also prevents the
|
||||
// host frame from carrying the same raw content a second time.
|
||||
expect('presentResult' in tool).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a post-policy spill preview in durable content without a result presenter', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
const preview = 'HEAD\n\n(Omitted 100 bytes. Full formatted result stored at: /tmp/run-code.txt.)\n\nTAIL'
|
||||
runtime.behavior = () => Promise.resolve({ logs: ['printed'], value: 'returned' })
|
||||
ctx.on('tools/post-execute', (exec, _result, next): Promise<PostToolDecision> => {
|
||||
if (exec.name !== RUN_CODE_NAME) return next()
|
||||
return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: preview }] })
|
||||
})
|
||||
// The result omits the title — an update replaces only provided fields,
|
||||
// so the pending card's program title persists through completion.
|
||||
expect(view).toEqual({
|
||||
card: 'generic',
|
||||
content: [{ type: 'text', text: 'printed' }],
|
||||
|
||||
const result = await runCode(ctx, 'return 1')
|
||||
const tool = ctx.tools.get(RUN_CODE_NAME)!
|
||||
|
||||
expect(result.content).toEqual([{ type: 'text', text: preview }])
|
||||
expect('presentResult' in tool).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps canonical failure content durable without a result presenter', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
runtime.behavior = () => Promise.resolve({
|
||||
logs: ['captured before failure'],
|
||||
error: { kind: 'output-limit', message: 'outer output exceeded 8 bytes' },
|
||||
})
|
||||
// No captured output → no content either; everything pending persists.
|
||||
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { logs: [] } }))
|
||||
.toEqual({ card: 'generic' })
|
||||
// Replay with an unrecognizable meta falls back to the generic rendering.
|
||||
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { logs: [{ text: 'legacy' }], dispatches: 1 } })).toBeUndefined()
|
||||
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { other: true } })).toBeUndefined()
|
||||
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false })).toBeUndefined()
|
||||
|
||||
const result = await runCode(ctx, 'return 1')
|
||||
const tool = ctx.tools.get(RUN_CODE_NAME)!
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content).toEqual([{
|
||||
type: 'text',
|
||||
text: 'Error: code run failed (output-limit): outer output exceeded 8 bytes\nCaptured output:\ncaptured before failure',
|
||||
}])
|
||||
expect('presentResult' in tool).toBe(false)
|
||||
})
|
||||
|
||||
it('renders non-text sub-result blocks as placeholders and truncates long event summaries', async () => {
|
||||
@@ -695,11 +769,15 @@ describe('the run_code dispatch bridge', () => {
|
||||
name: 'mixed',
|
||||
description: 'Returns mixed content.',
|
||||
parameters: {},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: () => [
|
||||
{ type: 'text', text: long },
|
||||
{ type: 'reasoning', text: 'hidden' },
|
||||
],
|
||||
},
|
||||
execute() {
|
||||
return Promise.resolve([
|
||||
{ type: 'text' as const, text: long },
|
||||
{ type: 'reasoning' as const, text: 'hidden' },
|
||||
])
|
||||
return Promise.resolve('mixed-value')
|
||||
},
|
||||
}))
|
||||
runtime.behavior = async (request) => {
|
||||
@@ -708,7 +786,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
}
|
||||
const result = await runCode(ctx, 'program', { agent })
|
||||
expect(result.isError).toBe(false)
|
||||
expect((result.content[0] as { text: string }).text).toBe(`${long}\n[reasoning content]`)
|
||||
expect((result.content[0] as { text: string }).text).toBe('mixed-value')
|
||||
const dispatch = events.find(event => event.type === 'tool/code-dispatch')?.data as SessionEventMap['tool/code-dispatch']
|
||||
expect(dispatch.resultSummary.length).toBe(201)
|
||||
expect(dispatch.resultSummary.endsWith('…')).toBe(true)
|
||||
@@ -720,9 +798,13 @@ describe('the run_code dispatch bridge', () => {
|
||||
name: 'workspace_path',
|
||||
description: 'Return a path beneath the session workspace.',
|
||||
parameters: {},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args, value) => [{ type: 'text', text: value }],
|
||||
},
|
||||
execute(_args, exec) {
|
||||
const cwd = exec.agent?.session.header.cwd ?? ''
|
||||
return Promise.resolve([{ type: 'text' as const, text: `<path>${cwd}/nested/task.txt</path>\n${'x'.repeat(240)}` }])
|
||||
return Promise.resolve(`<path>${cwd}/nested/task.txt</path>\n${'x'.repeat(240)}`)
|
||||
},
|
||||
}))
|
||||
runtime.behavior = async request => ({
|
||||
@@ -760,7 +842,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
expect((root.events[0]!.data as SessionEventMap['tool/code-dispatch']).resultSummary).toBe('echo:/workspace/value')
|
||||
})
|
||||
|
||||
it('rejects undefined, JSON-throwing, and JSON-unrepresentable binding arguments BEFORE dispatch', async () => {
|
||||
it('rejects undefined, getter-throwing, exotic, and unrepresentable binding arguments before dispatch', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
const calls = registerEcho(ctx)
|
||||
const { agent, events } = fakeAgent()
|
||||
@@ -773,8 +855,9 @@ describe('the run_code dispatch bridge', () => {
|
||||
// Root undefined must reject up front: the event log rejects it as
|
||||
// data, and nothing may execute unlogged.
|
||||
await catchMessage(echo(undefined)),
|
||||
// A toJSON that throws a NON-Error propagates out of JSON.stringify.
|
||||
await catchMessage(echo({ toJSON() { throw 'raw-throw' } })),
|
||||
await catchMessage(echo(Object.defineProperty({}, 'bad', { enumerable: true, get() { throw 'raw-throw' } }))),
|
||||
await catchMessage(echo(Object.defineProperty({}, 'bad', { enumerable: true, get() { throw new Error('error-throw') } }))),
|
||||
await catchMessage(echo(new Date(0))),
|
||||
// A bare function is a value JSON cannot represent at all.
|
||||
await catchMessage(echo(() => 1)),
|
||||
].join(' | '),
|
||||
@@ -783,18 +866,70 @@ describe('the run_code dispatch bridge', () => {
|
||||
const result = await runCode(ctx, 'program', { agent })
|
||||
const text = (result.content[0] as { text: string }).text
|
||||
expect(text).toContain('call the tool with an arguments object')
|
||||
expect(text).toContain('JSON-serializable: raw-throw')
|
||||
expect(text).toContain('a value JSON cannot represent')
|
||||
// None of the three dispatched, none logged.
|
||||
expect(text).toContain('lossless JSON: raw-throw')
|
||||
expect(text).toContain('lossless JSON: error-throw')
|
||||
expect(text.match(/tool arguments must be lossless JSON/g)).toHaveLength(5)
|
||||
// None dispatched or logged.
|
||||
expect(calls).toEqual([])
|
||||
expect(events.filter(event => event.type === 'tool/code-dispatch')).toEqual([])
|
||||
})
|
||||
|
||||
it('dispatches and durably logs binding arguments deeper than the structured-clone call stack', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
const depth = 5_000
|
||||
let observedDepth = 0
|
||||
let observedLeaf: JsonValue | undefined
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'deep_args',
|
||||
description: 'Measure a deeply nested JSON argument.',
|
||||
parameters: { nested: { type: 'json', required: true } },
|
||||
output: {
|
||||
schema: { type: 'integer' },
|
||||
render: (_args, value) => [{ type: 'text', text: String(value) }],
|
||||
},
|
||||
execute(args) {
|
||||
let cursor = args.nested
|
||||
while (Array.isArray(cursor)) {
|
||||
if (cursor.length !== 1) throw new Error('expected one item per nesting layer')
|
||||
observedDepth++
|
||||
cursor = cursor[0]!
|
||||
}
|
||||
observedLeaf = cursor
|
||||
return Promise.resolve(observedDepth)
|
||||
},
|
||||
}))
|
||||
const session = new Session(SessionId('deep-code-arguments'))
|
||||
const agent = { session } as Agent
|
||||
runtime.behavior = async (request) => {
|
||||
let nested: JsonValue = 'leaf'
|
||||
for (let index = 0; index < depth; index++) nested = [nested]
|
||||
const value = await request.bindings[0]!.functions.deep_args!({ nested })
|
||||
return { logs: [], value }
|
||||
}
|
||||
|
||||
const result = await runCode(ctx, 'return tools.deep_args(...)', { agent })
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.isError ? undefined : result.value).toEqual({ logs: [], result: depth })
|
||||
expect({ observedDepth, observedLeaf }).toEqual({ observedDepth: depth, observedLeaf: 'leaf' })
|
||||
const dispatch = session.events.find(event => event.type === 'tool/code-dispatch')
|
||||
if (dispatch === undefined) throw new Error('expected a durable tool/code-dispatch event')
|
||||
const logged = dispatch.data.arguments as { nested: JsonValue }
|
||||
let loggedDepth = 0
|
||||
let loggedCursor = logged.nested
|
||||
while (Array.isArray(loggedCursor)) {
|
||||
if (loggedCursor.length !== 1) throw new Error('expected one logged item per nesting layer')
|
||||
loggedDepth++
|
||||
loggedCursor = loggedCursor[0]!
|
||||
}
|
||||
expect({ loggedDepth, loggedCursor }).toEqual({ loggedDepth: depth, loggedCursor: 'leaf' })
|
||||
})
|
||||
|
||||
it('gives the tool and durable log the same immutable argument value', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
const { agent, events } = fakeAgent()
|
||||
let mutationSucceeded: boolean | undefined
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'mutator',
|
||||
description: 'Attempts to mutate its args object.',
|
||||
parameters: { list: { type: 'array', required: true } },
|
||||
@@ -820,7 +955,11 @@ describe('the run_code dispatch bridge', () => {
|
||||
name: '__proto__',
|
||||
description: 'A prototype-colliding tool name.',
|
||||
parameters: {},
|
||||
execute() { return Promise.resolve([{ type: 'text' as const, text: 'proto-tool-ok' }]) },
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args, value) => [{ type: 'text', text: value }],
|
||||
},
|
||||
execute() { return Promise.resolve('proto-tool-ok') },
|
||||
}))
|
||||
runtime.behavior = async (request) => {
|
||||
const functions = request.bindings[0]!.functions
|
||||
@@ -833,11 +972,48 @@ describe('the run_code dispatch bridge', () => {
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'proto-tool-ok' })
|
||||
})
|
||||
|
||||
it('renders a non-string completion value inspect-style', async () => {
|
||||
it('renders every non-string JSON root as pretty JSON while preserving strings raw', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
runtime.behavior = () => Promise.resolve({ logs: [], value: { n: 42 } })
|
||||
const result = await runCode(ctx, 'program')
|
||||
expect((result.content[0] as { text: string }).text).toBe('{ n: 42 }')
|
||||
runtime.behavior = () => Promise.resolve({ logs: [], value: { n: 42, ok: true } })
|
||||
expect((await runCode(ctx, 'object')).content[0]).toEqual({ type: 'text', text: '{\n "n": 42,\n "ok": true\n}' })
|
||||
runtime.behavior = () => Promise.resolve({ logs: [], value: {} })
|
||||
expect((await runCode(ctx, 'empty object')).content[0]).toEqual({ type: 'text', text: '{}' })
|
||||
const nested = { outer: [{ inner: true }] }
|
||||
runtime.behavior = () => Promise.resolve({ logs: [], value: nested })
|
||||
expect((await runCode(ctx, 'nested')).content[0]).toEqual({ type: 'text', text: JSON.stringify(nested, null, 2) })
|
||||
runtime.behavior = () => Promise.resolve({ logs: [], value: ['x', 2] })
|
||||
expect((await runCode(ctx, 'array')).content[0]).toEqual({ type: 'text', text: '[\n "x",\n 2\n]' })
|
||||
runtime.behavior = () => Promise.resolve({ logs: [], value: [] })
|
||||
expect((await runCode(ctx, 'empty array')).content[0]).toEqual({ type: 'text', text: '[]' })
|
||||
runtime.behavior = () => Promise.resolve({ logs: [], value: null })
|
||||
expect((await runCode(ctx, 'null')).content[0]).toEqual({ type: 'text', text: 'null' })
|
||||
runtime.behavior = () => Promise.resolve({ logs: [], value: 'raw' })
|
||||
expect((await runCode(ctx, 'string')).content[0]).toEqual({ type: 'text', text: 'raw' })
|
||||
runtime.behavior = () => Promise.resolve({ logs: [] })
|
||||
const absent = await runCode(ctx, 'undefined')
|
||||
expect(absent.content[0]).toEqual({ type: 'text', text: '(run_code completed with no output)' })
|
||||
expect(absent.isError ? undefined : absent.value).toEqual({ logs: [] })
|
||||
})
|
||||
|
||||
it('renders deeply nested JSON without recursive traversal or quadratic indentation', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
let value: JsonValue = {
|
||||
emptyArray: [],
|
||||
emptyObject: {},
|
||||
pair: ['leaf', 2],
|
||||
record: { first: true, second: null },
|
||||
}
|
||||
for (let depth = 0; depth < 5_000; depth++) value = [value]
|
||||
runtime.behavior = () => Promise.resolve({ logs: [], value })
|
||||
|
||||
const result = await runCode(ctx, 'deep result')
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
const text = (result.content[0] as { type: 'text'; text: string }).text
|
||||
expect(text.startsWith('[\n [\n [')).toBe(true)
|
||||
expect(text).toContain('"leaf"')
|
||||
expect(text.endsWith(']')).toBe(true)
|
||||
expect(text.length).toBeLessThan(11_000)
|
||||
})
|
||||
|
||||
it('short-circuits a pre-aborted outer signal before the code runtime', async () => {
|
||||
@@ -855,7 +1031,10 @@ describe('the run_code dispatch bridge', () => {
|
||||
expect(result).toEqual({
|
||||
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 },
|
||||
},
|
||||
})
|
||||
expect(runtime.lastRequest).toBeUndefined()
|
||||
expect(calls).toEqual([])
|
||||
@@ -873,7 +1052,10 @@ describe('the run_code dispatch bridge', () => {
|
||||
}
|
||||
const result = await runCode(ctx, 'program', { signal: controller.signal })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toEqual({ name: 'AbortError', code: 'ABORTED' })
|
||||
expect(result.error).toEqual({
|
||||
message: 'tool call aborted',
|
||||
info: { name: 'AbortError', code: 'ABORTED' },
|
||||
})
|
||||
expect((result.content[0] as { text: string }).text).toBe('Error: tool call aborted')
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, {
|
||||
defineTool,
|
||||
defineContentToolFixture,
|
||||
type ToolDefinition,
|
||||
type ToolExecutionInput,
|
||||
type ToolExecutionMode,
|
||||
@@ -27,7 +27,7 @@ function exec(name: string, args: unknown): ToolExecutionInput {
|
||||
describe('ToolRegistry.executionMode', () => {
|
||||
it('returns parallel only for an explicit true classifier', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'safe',
|
||||
description: 'parallel-safe',
|
||||
parameters: {},
|
||||
@@ -39,7 +39,7 @@ describe('ToolRegistry.executionMode', () => {
|
||||
|
||||
it('defaults to exclusive for a tool with no isConcurrencySafe declaration', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'plain',
|
||||
description: 'no declaration',
|
||||
parameters: {},
|
||||
@@ -55,7 +55,7 @@ describe('ToolRegistry.executionMode', () => {
|
||||
|
||||
it('returns exclusive when the classifier returns false for these args', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'rw',
|
||||
description: 'read or write',
|
||||
parameters: { mode: { type: 'string', required: true } },
|
||||
@@ -66,9 +66,9 @@ describe('ToolRegistry.executionMode', () => {
|
||||
expect(ctx.tools.executionMode(exec('rw', { mode: 'write' }))).toEqual({ kind: 'exclusive' })
|
||||
})
|
||||
|
||||
it('classifies invalid defineTool arguments as exclusive without throwing', async () => {
|
||||
it('classifies invalid defineContentToolFixture arguments as exclusive without throwing', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'needs-mode',
|
||||
description: 'requires mode',
|
||||
parameters: { mode: { type: 'string', required: true } },
|
||||
@@ -84,8 +84,9 @@ describe('ToolRegistry.executionMode', () => {
|
||||
name: 'thrower',
|
||||
description: 'classifier throws',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
output: { schema: { type: 'null' }, render: () => [] },
|
||||
isConcurrencySafe() { throw new Error('boom') },
|
||||
async execute() { return [] },
|
||||
async execute() { return null },
|
||||
}
|
||||
ctx.tools.register(raw)
|
||||
expect(ctx.tools.executionMode(exec('thrower', {}))).toEqual({ kind: 'exclusive' })
|
||||
@@ -97,8 +98,9 @@ describe('ToolRegistry.executionMode', () => {
|
||||
name: 'truthy',
|
||||
description: 'classifier returns a truthy string',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
output: { schema: { type: 'null' }, render: () => [] },
|
||||
isConcurrencySafe() { return 'yes' },
|
||||
async execute() { return [] },
|
||||
async execute() { return null },
|
||||
} as unknown as ToolDefinition
|
||||
ctx.tools.register(raw)
|
||||
expect(ctx.tools.executionMode(exec('truthy', {}))).toEqual({ kind: 'exclusive' })
|
||||
@@ -111,8 +113,9 @@ describe('ToolRegistry.executionMode', () => {
|
||||
name: 'raw-safe',
|
||||
description: 'raw',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
output: { schema: { type: 'null' }, render: () => [] },
|
||||
isConcurrencySafe(args) { seen = args; return true },
|
||||
async execute() { return [] },
|
||||
async execute() { return null },
|
||||
})
|
||||
expect(ctx.tools.executionMode(exec('raw-safe', { anything: 1 }))).toEqual({ kind: 'parallel' })
|
||||
expect(seen).toEqual({ anything: 1 })
|
||||
@@ -120,7 +123,7 @@ describe('ToolRegistry.executionMode', () => {
|
||||
|
||||
it('isConcurrencySafe never reaches the model-facing schemas() projection', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(defineTool({
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'safe',
|
||||
description: 'parallel-safe',
|
||||
parameters: { x: { type: 'string', required: true } },
|
||||
|
||||
@@ -80,11 +80,15 @@ const inferredTool = defineTool({
|
||||
name: 'signal-inference',
|
||||
description: 'Pins contextual signal inference.',
|
||||
parameters: {},
|
||||
output: {
|
||||
schema: { type: 'null' },
|
||||
render: () => [],
|
||||
},
|
||||
async execute(_args, exec) {
|
||||
expectTypeOf(exec.signal).toEqualTypeOf<AbortSignal>()
|
||||
// @ts-expect-error -- defineTool contextually exposes a readonly signal.
|
||||
exec.signal = new AbortController().signal
|
||||
return []
|
||||
return null
|
||||
},
|
||||
})
|
||||
void inferredTool
|
||||
|
||||
@@ -27,6 +27,7 @@ const execution = (overrides: Partial<ToolExecution> = {}): ToolExecution => ({
|
||||
const outcome = (): ToolExecutionResult => Object.freeze({
|
||||
content: Object.freeze([{ type: 'text' as const, text: 'ok' }]) as never,
|
||||
isError: false,
|
||||
value: null,
|
||||
})
|
||||
|
||||
function emitResult(ctx: Context, exec: ToolExecution, result: ToolExecutionResult): void {
|
||||
@@ -78,7 +79,7 @@ describe('tool-pipeline invariants', () => {
|
||||
expect(() => { emitResult(ctx, execution(), outcome()) }).toThrow(/execution must be frozen/)
|
||||
|
||||
const exec = Object.freeze(execution())
|
||||
expect(() => { emitResult(ctx, exec, { content: [], isError: false }) })
|
||||
expect(() => { emitResult(ctx, exec, { content: [], isError: false, value: null }) })
|
||||
.toThrow(/outcome and content must be frozen/)
|
||||
|
||||
const anonymous = Object.freeze(execution({ name: '' }))
|
||||
|
||||
@@ -1,304 +1,455 @@
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assertSupportedOutputSchema,
|
||||
OutputSchemaError,
|
||||
validateStructuredValue,
|
||||
type StructuredOutputSchema,
|
||||
} from '../src/json-schema.ts'
|
||||
assertObjectJsonSchema,
|
||||
assertSupportedJsonSchema,
|
||||
JsonSchemaError,
|
||||
validateJsonSchemaValue,
|
||||
type JsonSchemaNode,
|
||||
type ObjectJsonSchema,
|
||||
} from '../src/index.ts'
|
||||
|
||||
/** Assert-and-narrow helper: the asserted schema, typed. */
|
||||
function asserted(schema: unknown): StructuredOutputSchema {
|
||||
assertSupportedOutputSchema(schema)
|
||||
function asserted(schema: unknown): JsonSchemaNode {
|
||||
assertSupportedJsonSchema(schema)
|
||||
return schema
|
||||
}
|
||||
|
||||
/** The violations OutputSchemaError carries for a bad schema (throws if it passes). */
|
||||
function violationsOf(schema: unknown): string[] {
|
||||
try {
|
||||
assertSupportedOutputSchema(schema)
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof OutputSchemaError) return error.violations
|
||||
throw error
|
||||
}
|
||||
throw new Error('expected the schema to be rejected')
|
||||
function assertedObject(schema: unknown): ObjectJsonSchema {
|
||||
assertObjectJsonSchema(schema)
|
||||
return schema
|
||||
}
|
||||
|
||||
describe('assertSupportedOutputSchema', () => {
|
||||
it('accepts a representative subset schema (all supported keywords)', () => {
|
||||
const schema = asserted({
|
||||
type: 'object',
|
||||
description: 'a finding',
|
||||
title: 'Finding',
|
||||
properties: {
|
||||
file: { type: 'string', description: 'path' },
|
||||
line: { type: 'integer' },
|
||||
severity: { type: 'string', enum: ['low', 'high'] },
|
||||
kind: { type: 'string', const: 'bug' },
|
||||
score: { type: 'number' },
|
||||
confirmed: { type: 'boolean' },
|
||||
parent: { type: 'null' },
|
||||
tags: { type: 'array', items: { type: 'string' } },
|
||||
nested: {
|
||||
type: 'object',
|
||||
properties: { x: { type: 'number', default: 3, examples: [1, 2] } },
|
||||
additionalProperties: false,
|
||||
function violationsOf(schema: unknown, objectRoot = false): string[] {
|
||||
try {
|
||||
if (objectRoot) assertObjectJsonSchema(schema)
|
||||
else assertSupportedJsonSchema(schema)
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof JsonSchemaError) return error.violations
|
||||
throw error
|
||||
}
|
||||
throw new Error('expected schema rejection')
|
||||
}
|
||||
|
||||
function recordWithForgedIntrinsicPrototype(
|
||||
own: Record<string, unknown>,
|
||||
inherited: Record<string, unknown> = {},
|
||||
revoked = false,
|
||||
): Record<string, unknown> {
|
||||
const prototype = Object.assign(Object.create(null) as Record<string, unknown>, inherited)
|
||||
const ForgedObject = function ForgedObject(): void {}
|
||||
Object.defineProperty(ForgedObject, 'name', { value: 'Object' })
|
||||
ForgedObject.prototype = prototype
|
||||
const constructor = revoked ? Proxy.revocable(ForgedObject, {}) : undefined
|
||||
if (constructor !== undefined) constructor.revoke()
|
||||
Object.defineProperty(prototype, 'constructor', { value: constructor?.proxy ?? ForgedObject })
|
||||
return Object.assign(Object.create(prototype) as Record<string, unknown>, own)
|
||||
}
|
||||
|
||||
describe('the enforced raw JSON Schema subset', () => {
|
||||
it('accepts every JSON root and every supported node', () => {
|
||||
for (const schema of [
|
||||
{ type: 'string' },
|
||||
{ type: 'number' },
|
||||
{ type: 'integer' },
|
||||
{ type: 'boolean' },
|
||||
{ type: 'null' },
|
||||
{ type: 'array', items: { type: 'string' } },
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
nested: { type: 'object', properties: {}, additionalProperties: false },
|
||||
free: {},
|
||||
},
|
||||
anything: { type: 'array' },
|
||||
required: ['nested'],
|
||||
additionalProperties: true,
|
||||
},
|
||||
required: ['file', 'line'],
|
||||
additionalProperties: true,
|
||||
})
|
||||
expect(schema.type).toBe('object')
|
||||
{ oneOf: [{ type: 'string' }, { type: 'number' }] },
|
||||
{ description: 'any JSON', title: 'JSON', default: null, examples: [1, 'x'] },
|
||||
]) {
|
||||
expect(() => { assertSupportedJsonSchema(schema) }, JSON.stringify(schema)).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a non-object root (scalar/array-rooted schemas)', () => {
|
||||
expect(violationsOf({ type: 'string' })).toEqual(['schema.type must be "object" (structured output is object-rooted)'])
|
||||
expect(violationsOf({ type: 'array', items: { type: 'string' } }))
|
||||
.toContain('schema.type must be "object" (structured output is object-rooted)')
|
||||
it('retains an object-root guard only at consumers that need it', () => {
|
||||
expect(assertedObject({ type: 'object' }).type).toBe('object')
|
||||
for (const schema of [{}, { type: 'string' }, { type: 'array' }, { oneOf: [{ type: 'string' }, { type: 'null' }] }]) {
|
||||
expect(violationsOf(schema, true)).toEqual(['schema.type must be "object" (structured output is object-rooted)'])
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects non-object schema nodes and missing/unknown type', () => {
|
||||
expect(violationsOf('nope')).toEqual(['schema must be a schema object'])
|
||||
it('rejects non-schema nodes, unknown types, and type arrays', () => {
|
||||
expect(violationsOf(null)).toEqual(['schema must be a schema object'])
|
||||
expect(violationsOf([])).toEqual(['schema must be a schema object'])
|
||||
expect(violationsOf({})).toEqual(['schema.type must be one of object/array/string/number/integer/boolean/null'])
|
||||
expect(violationsOf('no')).toEqual(['schema must be a schema object'])
|
||||
expect(violationsOf({ type: 'tuple' })[0]).toMatch(/type must be one of/)
|
||||
expect(violationsOf({ type: 'object', properties: { a: 'str' } })).toEqual(['schema.properties.a must be a schema object'])
|
||||
})
|
||||
|
||||
it('rejects type ARRAYS with a dedicated message', () => {
|
||||
expect(violationsOf({ type: ['string', 'null'] }))
|
||||
.toEqual(['schema.type must be a single type string (type arrays are not supported)'])
|
||||
})
|
||||
|
||||
it('rejects unsupported constraint keywords loudly (never accepted-then-ignored)', () => {
|
||||
for (const keyword of ['oneOf', 'anyOf', 'allOf', 'not', 'pattern', 'minimum', 'maxLength', '$ref']) {
|
||||
const bad = violationsOf({ type: 'object', [keyword]: [] })
|
||||
expect(bad.some(v => v.includes(`schema.${keyword} is not a supported keyword`))).toBe(true)
|
||||
}
|
||||
it('enforces oneOf vocabulary and its minimum branch count', () => {
|
||||
expect(violationsOf({ oneOf: [] })).toEqual(['schema.oneOf must be an array of at least two schemas'])
|
||||
expect(violationsOf({ oneOf: [{}] })).toEqual(['schema.oneOf must be an array of at least two schemas'])
|
||||
expect(violationsOf({ oneOf: 'x' })).toEqual(['schema.oneOf must be an array of at least two schemas'])
|
||||
expect(violationsOf({ type: 'string', oneOf: [{}, {}] }))
|
||||
.toEqual(['schema cannot declare both type and oneOf'])
|
||||
expect(violationsOf({ oneOf: [{ type: 'string' }, { type: 'number' }], items: {} }))
|
||||
.toEqual(['schema.items is not supported beside oneOf'])
|
||||
expect(violationsOf({ oneOf: [{ type: 'string' }, { type: 'weird' }] })[0])
|
||||
.toContain('schema.oneOf[1].type')
|
||||
const sparse = new Array<unknown>(2)
|
||||
sparse[0] = { type: 'string' }
|
||||
expect(violationsOf({ oneOf: sparse }))
|
||||
.toEqual(['schema.oneOf must be an array of at least two schemas'])
|
||||
const compensatedSparse = new Array<unknown>(2)
|
||||
compensatedSparse[0] = { type: 'string' }
|
||||
Object.defineProperty(compensatedSparse, 'extra', { value: true })
|
||||
expect(violationsOf({ oneOf: compensatedSparse }))
|
||||
.toEqual(['schema.oneOf must be an array of at least two schemas'])
|
||||
class ExoticBranches extends Array<unknown> {}
|
||||
expect(violationsOf({ oneOf: new ExoticBranches({ type: 'string' }, { type: 'null' }) }))
|
||||
.toEqual(['schema.oneOf must be an array of at least two schemas'])
|
||||
const explosiveArray = new Proxy([{ type: 'string' }, { type: 'null' }], {
|
||||
getPrototypeOf() { throw new Error('prototype trap') },
|
||||
})
|
||||
expect(violationsOf({ oneOf: explosiveArray }))
|
||||
.toEqual(['schema.oneOf must be an array of at least two schemas'])
|
||||
})
|
||||
|
||||
it('reports EVERY violation, not just the first', () => {
|
||||
const bad = violationsOf({
|
||||
it('rejects unknown and misplaced keywords without accepted-then-ignored behavior', () => {
|
||||
for (const keyword of ['anyOf', 'allOf', 'not', 'pattern', 'minimum', 'maxLength', '$ref']) {
|
||||
expect(violationsOf({ type: 'object', [keyword]: [] })[0]).toContain(`schema.${keyword} is not a supported keyword`)
|
||||
}
|
||||
expect(violationsOf({ type: 'object', items: {} }))
|
||||
.toEqual(['schema.items is not supported on type "object"'])
|
||||
expect(violationsOf({ type: 'array', properties: {} }))
|
||||
.toEqual(['schema.properties is not supported on type "array"'])
|
||||
expect(violationsOf({ type: 'object', enum: ['x'] }))
|
||||
.toEqual(['schema.enum is not supported on type "object"'])
|
||||
expect(violationsOf({ type: 'array', const: null }))
|
||||
.toEqual(['schema.const is not supported on type "array"'])
|
||||
expect(violationsOf({ properties: {}, required: [], additionalProperties: true, items: {}, enum: [], const: null }))
|
||||
.toEqual([
|
||||
'schema.properties requires type or oneOf',
|
||||
'schema.required requires type or oneOf',
|
||||
'schema.additionalProperties requires type or oneOf',
|
||||
'schema.items requires type or oneOf',
|
||||
'schema.enum requires type or oneOf',
|
||||
'schema.const requires type or oneOf',
|
||||
])
|
||||
})
|
||||
|
||||
it('reports every independent schema violation', () => {
|
||||
expect(violationsOf({
|
||||
type: 'object',
|
||||
pattern: 'x',
|
||||
properties: { a: { type: 'weird' }, b: { type: 'string', minimum: 1 } },
|
||||
})
|
||||
expect(bad.length).toBe(3)
|
||||
})).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('rejects keywords on the wrong type (items on object, properties on string, enum on object)', () => {
|
||||
expect(violationsOf({ type: 'object', items: { type: 'string' } }))
|
||||
.toEqual(['schema.items is not supported on type "object"'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', properties: {} } } }))
|
||||
.toEqual(['schema.properties.a.properties is not supported on type "string"'])
|
||||
expect(violationsOf({ type: 'object', enum: [1] }))
|
||||
.toEqual(['schema.enum is not supported on type "object"'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'array', const: 1 } } }))
|
||||
.toEqual(['schema.properties.a.const is not supported on type "array"'])
|
||||
})
|
||||
|
||||
it('validates required: must be string[] naming declared properties', () => {
|
||||
expect(violationsOf({ type: 'object', required: 'file' }))
|
||||
it('validates object properties, required names, and openness', () => {
|
||||
expect(violationsOf({ type: 'object', properties: [] }))
|
||||
.toEqual(['schema.properties must be an object of schemas'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: 'x' } }))
|
||||
.toEqual(['schema.properties.a must be a schema object'])
|
||||
expect(violationsOf({ type: 'object', required: 'a' }))
|
||||
.toEqual(['schema.required must be an array of strings'])
|
||||
expect(violationsOf({ type: 'object', required: [1] }))
|
||||
.toEqual(['schema.required must be an array of strings'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'string' } }, required: ['b'] }))
|
||||
.toEqual(['schema.required names "b" which is not in properties'])
|
||||
expect(violationsOf({ type: 'object', required: ['a'] }))
|
||||
.toEqual(['schema.required names "a" which is not in properties'])
|
||||
})
|
||||
|
||||
it('validates additionalProperties must be boolean and enum/const must be scalars', () => {
|
||||
expect(violationsOf({ type: 'object', additionalProperties: {} }))
|
||||
expect(violationsOf({ type: 'object', properties: {}, required: ['missing'] }))
|
||||
.toEqual(['schema.required names "missing" which is not in properties'])
|
||||
expect(violationsOf({ type: 'object', additionalProperties: 'yes' }))
|
||||
.toEqual(['schema.additionalProperties must be a boolean'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: [] } } }))
|
||||
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: [{}] } } }))
|
||||
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: 'x' } } }))
|
||||
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'number', enum: [Number.NaN] } } }))
|
||||
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', const: {} } } }))
|
||||
.toEqual(['schema.properties.a.const must be a scalar'])
|
||||
expect(violationsOf({ type: 'object', properties: undefined }))
|
||||
.toEqual(['schema.properties must be an object of schemas'])
|
||||
expect(violationsOf({ type: 'object', properties: undefined, required: ['missing'] }))
|
||||
.toEqual([
|
||||
'schema.properties must be an object of schemas',
|
||||
'schema.required names "missing" which is not in properties',
|
||||
])
|
||||
const sparseRequired = new Array<string>(1)
|
||||
expect(violationsOf({ type: 'object', required: sparseRequired }))
|
||||
.toEqual(['schema.required must be an array of strings'])
|
||||
})
|
||||
|
||||
it('rejects non-string description/title and non-JSON annotation payloads', () => {
|
||||
expect(violationsOf({ type: 'object', description: 7 }))
|
||||
.toEqual(['schema.description must be a string'])
|
||||
expect(violationsOf({ type: 'object', title: 7 }))
|
||||
.toEqual(['schema.title must be a string'])
|
||||
expect(violationsOf({ type: 'object', default: () => 1 }))
|
||||
.toEqual(['schema.default annotation must be JSON data'])
|
||||
expect(violationsOf({ type: 'object', examples: [undefined] }))
|
||||
.toEqual(['schema.examples annotation must be JSON data'])
|
||||
expect(violationsOf({ type: 'object', examples: [Number.POSITIVE_INFINITY] }))
|
||||
.toEqual(['schema.examples annotation must be JSON data'])
|
||||
// A cyclic annotation payload is caught by the JSON-data walk.
|
||||
const cyclicAnnotation: Record<string, unknown> = {}
|
||||
cyclicAnnotation.self = cyclicAnnotation
|
||||
expect(violationsOf({ type: 'object', default: cyclicAnnotation }))
|
||||
.toEqual(['schema.default annotation must be JSON data'])
|
||||
// Object/array annotations that ARE JSON data pass.
|
||||
asserted({ type: 'object', default: { a: [1, 'x', null, true] } })
|
||||
it('requires type-correct scalar enum and const values', () => {
|
||||
for (const schema of [
|
||||
{ type: 'string', enum: ['a'], const: 'a' },
|
||||
{ type: 'number', enum: [1.5], const: 1.5 },
|
||||
{ type: 'integer', enum: [1], const: 1 },
|
||||
{ type: 'boolean', enum: [true], const: true },
|
||||
{ type: 'null', enum: [null], const: null },
|
||||
]) {
|
||||
expect(() => { assertSupportedJsonSchema(schema) }, JSON.stringify(schema)).not.toThrow()
|
||||
}
|
||||
|
||||
expect(violationsOf({ type: 'string', enum: [] }))
|
||||
.toEqual(['schema.enum must be a non-empty array of string values'])
|
||||
expect(violationsOf({ type: 'number', enum: ['1'] }))
|
||||
.toEqual(['schema.enum must be a non-empty array of number values'])
|
||||
expect(violationsOf({ type: 'integer', enum: [1.5] }))
|
||||
.toEqual(['schema.enum must be a non-empty array of integer values'])
|
||||
expect(violationsOf({ type: 'number', enum: [Number.NaN] }))
|
||||
.toEqual(['schema.enum must be a non-empty array of number values'])
|
||||
expect(violationsOf({ type: 'number', const: -0 }))
|
||||
.toEqual(['schema.const must be a number value'])
|
||||
expect(violationsOf({ type: 'boolean', const: 1 }))
|
||||
.toEqual(['schema.const must be a boolean value'])
|
||||
expect(violationsOf({ type: 'string', enum: undefined }))
|
||||
.toEqual(['schema.enum must be a non-empty array of string values'])
|
||||
expect(violationsOf({ type: 'string', enum: ['a'], const: 'b' }))
|
||||
.toEqual(['schema.const must be one of schema.enum when both are declared'])
|
||||
const sparseEnum = new Array<string>(1)
|
||||
expect(violationsOf({ type: 'string', enum: sparseEnum }))
|
||||
.toEqual(['schema.enum must be a non-empty array of string values'])
|
||||
})
|
||||
|
||||
it('rejects a circular schema instead of recursing forever', () => {
|
||||
const node: Record<string, unknown> = { type: 'object' }
|
||||
node.properties = { self: node }
|
||||
expect(violationsOf(node)).toEqual(['schema.properties.self is circular'])
|
||||
it('validates annotation types and lossless JSON payloads', () => {
|
||||
expect(violationsOf({ description: 1 })).toEqual(['schema.description must be a string'])
|
||||
expect(violationsOf({ title: 1 })).toEqual(['schema.title must be a string'])
|
||||
for (const [key, value] of [
|
||||
['default', undefined],
|
||||
['examples', [undefined]],
|
||||
['default', Number.POSITIVE_INFINITY],
|
||||
['examples', new Date(0)],
|
||||
] as const) {
|
||||
expect(violationsOf({ [key]: value })).toEqual([`schema.${key} annotation must be lossless JSON data`])
|
||||
}
|
||||
const cyclic: Record<string, unknown> = {}
|
||||
cyclic.self = cyclic
|
||||
expect(violationsOf({ default: cyclic }))
|
||||
.toEqual(['schema.default annotation must be lossless JSON data'])
|
||||
|
||||
const explosive = new Proxy({}, {
|
||||
ownKeys() { throw new Error('annotation trap') },
|
||||
})
|
||||
expect(violationsOf({ examples: explosive }))
|
||||
.toEqual(['schema.examples annotation must be lossless JSON data'])
|
||||
expect(violationsOf({ default: Object.defineProperty({}, 'hidden', { value: true }) }))
|
||||
.toEqual(['schema.default annotation must be lossless JSON data'])
|
||||
expect(violationsOf({ default: { [Symbol('hidden')]: true } }))
|
||||
.toEqual(['schema.default annotation must be lossless JSON data'])
|
||||
})
|
||||
|
||||
it('accepts the same subschema object reused in two SIBLING positions (a DAG, not a cycle)', () => {
|
||||
it('accepts lossless annotation containers from another JavaScript realm', () => {
|
||||
const schema = runInNewContext(`({
|
||||
type: 'object',
|
||||
properties: { value: { type: 'string', enum: ['x'] } },
|
||||
required: ['value'],
|
||||
default: { x: 1 },
|
||||
examples: [[{ ok: true }]],
|
||||
})`) as unknown
|
||||
|
||||
expect(() => { assertSupportedJsonSchema(schema) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects cyclic/exotic schema structure but permits sibling reuse', () => {
|
||||
const cyclic: Record<string, unknown> = { type: 'object' }
|
||||
cyclic.properties = { self: cyclic }
|
||||
expect(violationsOf(cyclic)).toEqual(['schema.properties.self is circular'])
|
||||
const leaf = { type: 'string' }
|
||||
asserted({ type: 'object', properties: { a: leaf, b: leaf } })
|
||||
expect(() => { assertSupportedJsonSchema({ type: 'object', properties: { a: leaf, b: leaf } }) }).not.toThrow()
|
||||
expect(violationsOf({ type: 'object', properties: new Map() }))
|
||||
.toEqual(['schema.properties must be an object of schemas'])
|
||||
expect(violationsOf({ type: 'object', properties: { at: new Date(0) } }))
|
||||
.toEqual(['schema.properties.at must be a schema object'])
|
||||
|
||||
const forgedSchema = recordWithForgedIntrinsicPrototype(
|
||||
{ type: 'object' },
|
||||
{ oneOf: [{ type: 'string' }, { type: 'null' }] },
|
||||
)
|
||||
expect(violationsOf(forgedSchema)).toEqual(['schema must be a schema object'])
|
||||
expect(violationsOf(forgedSchema, true)).toEqual(['schema must be a schema object'])
|
||||
expect(violationsOf(recordWithForgedIntrinsicPrototype({ type: 'string' }, {}, true)))
|
||||
.toEqual(['schema must be a schema object'])
|
||||
const prototypeWithoutConstructor = Object.create(null) as object
|
||||
expect(violationsOf(Object.create(prototypeWithoutConstructor) as unknown))
|
||||
.toEqual(['schema must be a schema object'])
|
||||
expect(violationsOf(Object.defineProperty({ type: 'string' }, 'hidden', { value: true })))
|
||||
.toEqual(['schema must be a schema object'])
|
||||
expect(violationsOf({ type: 'string', [Symbol('hidden')]: true }))
|
||||
.toEqual(['schema must be a schema object'])
|
||||
expect(violationsOf(new Proxy({}, {
|
||||
getPrototypeOf() { throw new Error('prototype trap') },
|
||||
}))).toEqual(['schema must be a schema object'])
|
||||
expect(violationsOf(new Proxy({}, {
|
||||
ownKeys() { throw new Error('keys trap') },
|
||||
}))).toEqual(['schema must be a schema object'])
|
||||
})
|
||||
|
||||
it('required cannot be satisfied by INHERITED names — `toString` is not a declared property', () => {
|
||||
// `'toString' in {}` is true via Object.prototype; the declared-property
|
||||
// contract must be an own-property check.
|
||||
it('asserts deeply nested raw unions without using the JavaScript call stack', () => {
|
||||
const depth = 5_000
|
||||
let schema: JsonSchemaNode = { type: 'string' }
|
||||
for (let index = 0; index < depth; index++) schema = { oneOf: [schema, { type: 'null' }] }
|
||||
|
||||
expect(() => { assertSupportedJsonSchema(schema) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('uses own-property semantics for required declarations', () => {
|
||||
expect(violationsOf({ type: 'object', properties: {}, required: ['toString'] }))
|
||||
.toEqual(['schema.required names "toString" which is not in properties'])
|
||||
})
|
||||
|
||||
it('rejects exotic host objects where the subset expects plain JSON structure', () => {
|
||||
// A Map as `properties` has no own enumerable entries: structurally it
|
||||
// would read as "no properties" and serialize to {} — lossy, not loud.
|
||||
expect(violationsOf({ type: 'object', properties: new Map() }))
|
||||
.toEqual(['schema.properties must be an object of schemas'])
|
||||
// A Date node is not a schema object even though Object.values(date) is [].
|
||||
expect(violationsOf({ type: 'object', properties: { at: new Date(0) } }))
|
||||
.toEqual(['schema.properties.at must be a schema object'])
|
||||
})
|
||||
|
||||
it('rejects exotic annotation payloads that would serialize lossily', () => {
|
||||
expect(violationsOf({ type: 'object', default: new Date(0) }))
|
||||
.toEqual(['schema.default annotation must be JSON data'])
|
||||
expect(violationsOf({ type: 'object', examples: [new Map()] }))
|
||||
.toEqual(['schema.examples annotation must be JSON data'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateStructuredValue', () => {
|
||||
const schema = asserted({
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: { type: 'string' },
|
||||
line: { type: 'integer' },
|
||||
score: { type: 'number' },
|
||||
confirmed: { type: 'boolean' },
|
||||
parent: { type: 'null' },
|
||||
severity: { type: 'string', enum: ['low', 'high'] },
|
||||
kind: { type: 'string', const: 'bug' },
|
||||
tags: { type: 'array', items: { type: 'string' } },
|
||||
free: { type: 'array' },
|
||||
nested: { type: 'object', properties: { x: { type: 'number' } }, required: ['x'], additionalProperties: false },
|
||||
},
|
||||
required: ['file'],
|
||||
describe('validateJsonSchemaValue', () => {
|
||||
it('validates scalar, array, object, and null roots', () => {
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'string' }), 'x')).toEqual([])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'number' }), 1.5)).toEqual([])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'integer' }), 2)).toEqual([])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'boolean' }), true)).toEqual([])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'null' }), null)).toEqual([])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'array', items: { type: 'string' } }), ['x'])).toEqual([])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'object' }), { x: 1 })).toEqual([])
|
||||
})
|
||||
|
||||
it('accepts a fully valid value (empty violations)', () => {
|
||||
expect(validateStructuredValue(schema, {
|
||||
file: 'a.ts', line: 3, score: 0.5, confirmed: true, parent: null,
|
||||
severity: 'high', kind: 'bug', tags: ['x'], free: [1, { any: true }], nested: { x: 1 },
|
||||
})).toEqual([])
|
||||
it('rejects wrong scalar types and lossy numbers', () => {
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'string' }), 1)).toEqual(['"value" must be a string'])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'number' }), '1')).toEqual(['"value" must be a number'])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'number' }), Number.NaN)).toEqual(['"value" must be a finite JSON number'])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'number' }), -0)).toEqual(['"value" must be a finite JSON number'])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'integer' }), 1.5)).toEqual(['"value" must be an integer'])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'boolean' }), 'true')).toEqual(['"value" must be a boolean'])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'null' }), 0)).toEqual(['"value" must be null'])
|
||||
})
|
||||
|
||||
it('reports missing required and wrong root type', () => {
|
||||
expect(validateStructuredValue(schema, {})).toEqual(['missing required property "value.file"'])
|
||||
expect(validateStructuredValue(schema, 'nope')).toEqual(['"value" must be an object'])
|
||||
expect(validateStructuredValue(schema, [])).toEqual(['"value" must be an object'])
|
||||
it('enforces scalar enum and const together', () => {
|
||||
const schema = asserted({ type: 'string', enum: ['a', 'b'], const: 'a' })
|
||||
expect(validateJsonSchemaValue(schema, 'a')).toEqual([])
|
||||
expect(validateJsonSchemaValue(schema, 'c')).toEqual(['"value" must be one of ["a","b"]'])
|
||||
expect(validateJsonSchemaValue(schema, 'b')).toEqual(['"value" must be "a"'])
|
||||
})
|
||||
|
||||
it('type-checks every scalar branch with path-qualified messages', () => {
|
||||
expect(validateStructuredValue(schema, { file: 1 })).toEqual(['"value.file" must be a string'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', line: 1.5 })).toEqual(['"value.line" must be an integer'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', line: 'x' })).toEqual(['"value.line" must be an integer'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', score: 'x' })).toEqual(['"value.score" must be a finite number'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', score: Number.NaN })).toEqual(['"value.score" must be a finite number'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', confirmed: 'yes' })).toEqual(['"value.confirmed" must be a boolean'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', parent: 0 })).toEqual(['"value.parent" must be null'])
|
||||
it('validates object requiredness, nested values, and raw open defaults', () => {
|
||||
const open = asserted({
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: { type: 'string' },
|
||||
nested: {
|
||||
type: 'object',
|
||||
properties: { line: { type: 'integer' } },
|
||||
required: ['line'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
required: ['file'],
|
||||
})
|
||||
expect(validateJsonSchemaValue(open, { file: 'a', extra: [1], nested: { line: 2 } })).toEqual([])
|
||||
expect(validateJsonSchemaValue(open, { nested: { line: 1 } }))
|
||||
.toEqual(['missing required property "value.file"'])
|
||||
expect(validateJsonSchemaValue(open, { file: 1, nested: {} })).toEqual([
|
||||
'"value.file" must be a string',
|
||||
'missing required property "value.nested.line"',
|
||||
])
|
||||
expect(validateJsonSchemaValue(open, { file: 'a', nested: { line: 1, extra: true } }))
|
||||
.toEqual(['"value.nested.extra" is not a declared property (additionalProperties: false)'])
|
||||
expect(validateJsonSchemaValue(open, 'x')).toEqual(['"value" must be an object'])
|
||||
})
|
||||
|
||||
it('enforces enum membership and const equality', () => {
|
||||
expect(validateStructuredValue(schema, { file: 'a', severity: 'mid' }))
|
||||
.toEqual(['"value.severity" must be one of ["low","high"]'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', kind: 'feature' }))
|
||||
.toEqual(['"value.kind" must be "bug"'])
|
||||
it('treats present undefined as missing when required, then rejects other lossy objects', () => {
|
||||
const required = asserted({ type: 'object', properties: { x: {} }, required: ['x'] })
|
||||
expect(validateJsonSchemaValue(required, { x: undefined }))
|
||||
.toEqual(['missing required property "value.x"'])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'object' }), { x: undefined }))
|
||||
.toEqual(['"value" must be a lossless JSON object'])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'object' }), new Date(0)))
|
||||
.toEqual(['"value" must be an object'])
|
||||
})
|
||||
|
||||
it('checks arrays per index; an items-less array accepts anything', () => {
|
||||
expect(validateStructuredValue(schema, { file: 'a', tags: 'x' })).toEqual(['"value.tags" must be an array'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', tags: ['ok', 2] })).toEqual(['"value.tags[1]" must be a string'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', free: [{ deep: [1] }, null] })).toEqual([])
|
||||
it('returns a violation instead of throwing for a container with a hostile getter', () => {
|
||||
const value = Object.defineProperty({}, 'answer', {
|
||||
enumerable: true,
|
||||
get() { throw new Error('getter exploded') },
|
||||
})
|
||||
const schema = asserted({
|
||||
type: 'object',
|
||||
properties: { answer: { type: 'integer' } },
|
||||
required: ['answer'],
|
||||
})
|
||||
|
||||
expect(validateJsonSchemaValue(schema, value))
|
||||
.toEqual(['"value" must be a lossless JSON value'])
|
||||
})
|
||||
|
||||
it('recurses into nested objects: required + additionalProperties: false', () => {
|
||||
expect(validateStructuredValue(schema, { file: 'a', nested: {} }))
|
||||
.toEqual(['missing required property "value.nested.x"'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', nested: { x: 1, y: 2 } }))
|
||||
.toEqual(['"value.nested.y" is not a declared property (additionalProperties: false)'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', nested: 3 }))
|
||||
.toEqual(['"value.nested" must be an object'])
|
||||
it('validates dense arrays per index and rejects lossy arrays', () => {
|
||||
const schema = asserted({ type: 'array', items: { type: 'integer' } })
|
||||
expect(validateJsonSchemaValue(schema, [1, 2])).toEqual([])
|
||||
expect(validateJsonSchemaValue(schema, runInNewContext('[1, 2]'))).toEqual([])
|
||||
expect(validateJsonSchemaValue(schema, [1, 1.5])).toEqual(['"value[1]" must be an integer'])
|
||||
expect(validateJsonSchemaValue(schema, 'x')).toEqual(['"value" must be an array'])
|
||||
const sparse: number[] = []
|
||||
sparse.length = 2
|
||||
sparse[0] = 1
|
||||
expect(validateJsonSchemaValue(schema, sparse)).toEqual(['"value" must be a dense lossless JSON array'])
|
||||
})
|
||||
|
||||
it('a required key present-but-undefined counts as missing', () => {
|
||||
expect(validateStructuredValue(schema, { file: undefined })).toEqual(['missing required property "value.file"'])
|
||||
it('validates exact-one oneOf semantics, including overlap', () => {
|
||||
const disjoint = asserted({ oneOf: [{ type: 'string' }, { type: 'number' }] })
|
||||
expect(validateJsonSchemaValue(disjoint, 'x')).toEqual([])
|
||||
expect(validateJsonSchemaValue(disjoint, null))
|
||||
.toEqual(['"value" must match exactly one oneOf branch (matched 0)'])
|
||||
const overlap = asserted({ oneOf: [{ type: 'number' }, { type: 'integer' }] })
|
||||
expect(validateJsonSchemaValue(overlap, 1))
|
||||
.toEqual(['"value" must match exactly one oneOf branch (matched 2)'])
|
||||
expect(validateJsonSchemaValue(overlap, 1.5)).toEqual([])
|
||||
})
|
||||
|
||||
it('inherited properties satisfy nothing: required, additionalProperties, and recursion are own-property only', () => {
|
||||
// required: ['toString'] must NOT be satisfied by Object.prototype.toString.
|
||||
expect(validateStructuredValue(
|
||||
it('validates deeply nested exact-one unions without using the JavaScript call stack', () => {
|
||||
const depth = 5_000
|
||||
let schema: JsonSchemaNode = { type: 'string' }
|
||||
for (let index = 0; index < depth; index++) schema = { oneOf: [schema, { type: 'null' }] }
|
||||
assertSupportedJsonSchema(schema)
|
||||
|
||||
expect(validateJsonSchemaValue(schema, 'leaf')).toEqual([])
|
||||
expect(validateJsonSchemaValue(schema, 42))
|
||||
.toEqual(['"value" must match exactly one oneOf branch (matched 0)'])
|
||||
})
|
||||
|
||||
it('an unconstrained schema accepts only lossless JSON values', () => {
|
||||
const anyJson = asserted({})
|
||||
for (const value of [null, true, 1, 'x', [1], { x: null }]) {
|
||||
expect(validateJsonSchemaValue(anyJson, value), JSON.stringify(value)).toEqual([])
|
||||
}
|
||||
for (const value of [undefined, () => 1, Number.POSITIVE_INFINITY, -0, new Map()]) {
|
||||
expect(validateJsonSchemaValue(anyJson, value)).toEqual(['"value" must be a lossless JSON value'])
|
||||
}
|
||||
const cyclic: Record<string, unknown> = {}
|
||||
cyclic.self = cyclic
|
||||
expect(validateJsonSchemaValue(anyJson, cyclic)).toEqual(['"value" must be a lossless JSON value'])
|
||||
const explosive = new Proxy({}, {
|
||||
ownKeys() { throw new Error('value trap') },
|
||||
})
|
||||
expect(validateJsonSchemaValue(anyJson, explosive)).toEqual(['"value" must be a lossless JSON value'])
|
||||
})
|
||||
|
||||
it('uses own properties for requiredness, recursion, and closed-object checks', () => {
|
||||
expect(validateJsonSchemaValue(
|
||||
asserted({ type: 'object', properties: { toString: { type: 'string' } }, required: ['toString'] }),
|
||||
{},
|
||||
)).toEqual(['missing required property "value.toString"'])
|
||||
// additionalProperties: false must flag an OWN `toString` key even though
|
||||
// `'toString' in properties` is true via the prototype.
|
||||
expect(validateStructuredValue(
|
||||
asserted({ type: 'object', additionalProperties: false }),
|
||||
{ toString: 1 },
|
||||
)).toEqual(['"value.toString" is not a declared property (additionalProperties: false)'])
|
||||
// A declared property the value does NOT carry must not be validated
|
||||
// against the value's INHERITED member (constructor is a function on
|
||||
// every plain object's prototype, not a carried property).
|
||||
expect(validateStructuredValue(
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'object', additionalProperties: false }), { toString: 1 }))
|
||||
.toEqual(['"value.toString" is not a declared property (additionalProperties: false)'])
|
||||
expect(validateJsonSchemaValue(
|
||||
asserted({ type: 'object', properties: { constructor: { type: 'string' } } }),
|
||||
{},
|
||||
)).toEqual([])
|
||||
|
||||
const inheritedUnion = Object.assign(
|
||||
Object.create({ oneOf: [{ type: 'string' }, { type: 'null' }] }) as JsonSchemaNode,
|
||||
{ type: 'object' as const },
|
||||
)
|
||||
expect(validateJsonSchemaValue(inheritedUnion, {})).toEqual([])
|
||||
expect(validateJsonSchemaValue(inheritedUnion, 'x')).toEqual(['"value" must be an object'])
|
||||
expect(validateJsonSchemaValue(
|
||||
{ type: 'object', properties: undefined } as unknown as JsonSchemaNode,
|
||||
{},
|
||||
)).toEqual([])
|
||||
expect(validateJsonSchemaValue(
|
||||
{ type: 'object', required: undefined } as unknown as JsonSchemaNode,
|
||||
{},
|
||||
)).toEqual([])
|
||||
})
|
||||
|
||||
it('a non-plain object value is not an object in the JSON sense', () => {
|
||||
expect(validateStructuredValue(asserted({ type: 'object' }), new Date(0)))
|
||||
.toEqual(['"value" must be an object'])
|
||||
})
|
||||
|
||||
it('collects multiple violations across branches in one pass', () => {
|
||||
expect(validateStructuredValue(schema, { line: 'x', severity: 'mid' })).toEqual([
|
||||
'missing required property "value.file"',
|
||||
'"value.line" must be an integer',
|
||||
'"value.severity" must be one of ["low","high"]',
|
||||
])
|
||||
})
|
||||
|
||||
it('null-typed const/enum work through the scalar path', () => {
|
||||
const nullish = asserted({ type: 'object', properties: { a: { type: 'null', const: null } } })
|
||||
expect(validateStructuredValue(nullish, { a: null })).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a non-object properties value in the schema walk', () => {
|
||||
expect(violationsOf({ type: 'object', properties: [] }))
|
||||
.toEqual(['schema.properties must be an object of schemas'])
|
||||
})
|
||||
|
||||
it('an object schema without properties/required only type-checks its value', () => {
|
||||
const bare = asserted({ type: 'object' })
|
||||
expect(validateStructuredValue(bare, { any: ['thing'] })).toEqual([])
|
||||
expect(validateStructuredValue(bare, 7)).toEqual(['"value" must be an object'])
|
||||
})
|
||||
|
||||
it('validateStructuredValue throws on a type the assert would never let through (assertNever backstop)', () => {
|
||||
const forged = { type: 'tuple' } as unknown as StructuredOutputSchema
|
||||
expect(() => validateStructuredValue(forged, 1)).toThrow(/tuple/)
|
||||
it('keeps assertNever as a forged-schema backstop', () => {
|
||||
const forged = { type: 'tuple' } as unknown as JsonSchemaNode
|
||||
expect(() => validateJsonSchemaValue(forged, 1)).toThrow(/tuple/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,61 +1,92 @@
|
||||
/**
|
||||
* Property-based tests for the tool-schema DSL (the property-testing Agent Note), including
|
||||
* the the property-testing ↔ runtime-validation composition composition: generated args that satisfy a SchemaSpec must
|
||||
* the property-testing ↔ runtime-validation composition: generated args that satisfy a ParameterSchemaSpec must
|
||||
* pass validateArgs, and targeted corruptions must be rejected. This closes the
|
||||
* validator/InferArgs drift risk noted in the arg-validation Agent Note.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import fc from 'fast-check'
|
||||
import { schemaSpecToJsonSchema, validateArgs } from '@deepseek-ai/dsh-tools'
|
||||
import type { SchemaProp, SchemaSpec } from '@deepseek-ai/dsh-tools'
|
||||
import { isJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import { parameterSchemaSpecToJsonSchema, validateArgs } from '@deepseek-ai/dsh-tools'
|
||||
import type { ParameterPropertySpec, ParameterSchemaSpec, ValueSchemaSpec } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/** Remove parameter-only requiredness before nesting a schema as an array item. */
|
||||
function asValueSchema(prop: ParameterPropertySpec): ValueSchemaSpec {
|
||||
const { required: _required, ...schema } = prop
|
||||
return schema
|
||||
}
|
||||
|
||||
// A leaf prop arbitrary (no nesting) with optional required/enum.
|
||||
function leafPropArb(): fc.Arbitrary<SchemaProp> {
|
||||
function leafPropArb(): fc.Arbitrary<ParameterPropertySpec> {
|
||||
return fc.oneof(
|
||||
fc.record({ required: fc.boolean() }).map(({ required }): SchemaProp => ({ type: 'string', ...required ? { required: true } : {} })),
|
||||
fc.record({ required: fc.boolean() }).map(({ required }): SchemaProp => ({ type: 'number', ...required ? { required: true } : {} })),
|
||||
fc.record({ required: fc.boolean() }).map(({ required }): SchemaProp => ({ type: 'boolean', ...required ? { required: true } : {} })),
|
||||
fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'string', ...required ? { required: true } : {} })),
|
||||
fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'number', ...required ? { required: true } : {} })),
|
||||
fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'integer', ...required ? { required: true } : {} })),
|
||||
fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'boolean', ...required ? { required: true } : {} })),
|
||||
fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'null', ...required ? { required: true } : {} })),
|
||||
fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'json', ...required ? { required: true } : {} })),
|
||||
fc.record({ values: fc.uniqueArray(fc.string({ minLength: 1 }), { minLength: 1, maxLength: 3 }), required: fc.boolean() })
|
||||
.map(({ values, required }): SchemaProp => ({ type: 'string', enum: values, ...required ? { required: true } : {} })),
|
||||
.map(({ values, required }): ParameterPropertySpec => ({ type: 'string', enum: values, ...required ? { required: true } : {} })),
|
||||
fc.record({ value: fc.string(), required: fc.boolean() })
|
||||
.map(({ value, required }): ParameterPropertySpec => ({ type: 'string', const: value, ...required ? { required: true } : {} })),
|
||||
fc.record({ required: fc.boolean() })
|
||||
.map(({ required }): ParameterPropertySpec => ({
|
||||
oneOf: [{ type: 'string' }, { type: 'null' }],
|
||||
...required ? { required: true } : {},
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/** A prop arbitrary up to `depth` levels of nesting (objects and arrays). */
|
||||
function propArb(depth: number): fc.Arbitrary<SchemaProp> {
|
||||
function propArb(depth: number): fc.Arbitrary<ParameterPropertySpec> {
|
||||
if (depth <= 0) return leafPropArb()
|
||||
return fc.oneof(
|
||||
{ weight: 3, arbitrary: leafPropArb() },
|
||||
{
|
||||
weight: 1,
|
||||
arbitrary: fc.record({ properties: specArb(depth - 1), required: fc.boolean() })
|
||||
.map(({ properties, required }): SchemaProp => ({ type: 'object', properties, ...required ? { required: true } : {} })),
|
||||
arbitrary: fc.record({ properties: specArb(depth - 1), required: fc.boolean(), additionalProperties: fc.boolean() })
|
||||
.map(({ properties, required, additionalProperties }): ParameterPropertySpec => ({
|
||||
type: 'object',
|
||||
additionalProperties,
|
||||
properties,
|
||||
...required ? { required: true } : {},
|
||||
})),
|
||||
},
|
||||
{
|
||||
weight: 1,
|
||||
arbitrary: fc.record({ items: propArb(depth - 1), required: fc.boolean() })
|
||||
.map(({ items, required }): SchemaProp => ({ type: 'array', items, ...required ? { required: true } : {} })),
|
||||
.map(({ items, required }): ParameterPropertySpec => ({
|
||||
type: 'array',
|
||||
items: asValueSchema(items),
|
||||
...required ? { required: true } : {},
|
||||
})),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function specArb(depth: number): fc.Arbitrary<SchemaSpec> {
|
||||
function specArb(depth: number): fc.Arbitrary<ParameterSchemaSpec> {
|
||||
return fc.dictionary(fc.string({ minLength: 1, maxLength: 6 }), propArb(depth), { maxKeys: 4 })
|
||||
}
|
||||
|
||||
/** Generate a value that satisfies a prop (used to build valid args). */
|
||||
function valueForProp(prop: SchemaProp): fc.Arbitrary<unknown> {
|
||||
function valueForProp(prop: ParameterPropertySpec): fc.Arbitrary<unknown> {
|
||||
if ('oneOf' in prop) return fc.oneof(...prop.oneOf.map(valueForProp))
|
||||
if ('const' in prop) return fc.constant(prop.const)
|
||||
switch (prop.type) {
|
||||
case 'string': return prop.enum ? fc.constantFrom(...prop.enum) : fc.string()
|
||||
case 'number': return fc.double({ noNaN: true, noDefaultInfinity: true })
|
||||
case 'number': return fc.double({ noNaN: true, noDefaultInfinity: true }).filter(value => !Object.is(value, -0))
|
||||
case 'integer': return fc.integer()
|
||||
case 'boolean': return fc.boolean()
|
||||
case 'null': return fc.constant(null)
|
||||
case 'object': return prop.properties ? validArgsForSpec(prop.properties) : fc.constant({})
|
||||
case 'array': return prop.items ? fc.array(valueForProp(prop.items), { maxLength: 3 }) : fc.constant([])
|
||||
case 'json': return fc.jsonValue().filter(value => isJsonValue(value))
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate args satisfying every required key of a spec (optionals included randomly). */
|
||||
function validArgsForSpec(spec: SchemaSpec): fc.Arbitrary<Record<string, unknown>> {
|
||||
function validArgsForSpec(spec: ParameterSchemaSpec): fc.Arbitrary<Record<string, unknown>> {
|
||||
const entries = Object.entries(spec)
|
||||
return fc.tuple(...entries.map(([key, prop]) =>
|
||||
fc.tuple(
|
||||
@@ -76,29 +107,29 @@ function validArgsForSpec(spec: SchemaSpec): fc.Arbitrary<Record<string, unknown
|
||||
}
|
||||
|
||||
/** Collect the `required: true` keys at the top level of a spec. */
|
||||
function requiredKeys(spec: SchemaSpec): string[] {
|
||||
function requiredKeys(spec: ParameterSchemaSpec): string[] {
|
||||
return Object.entries(spec).filter(([, p]) => p.required === true).map(([k]) => k)
|
||||
}
|
||||
|
||||
describe('schema DSL properties', () => {
|
||||
it('JSON Schema `required` equals the required:true keys at every level', () => {
|
||||
fc.assert(fc.property(specArb(2), (spec) => {
|
||||
const checkLevel = (s: SchemaSpec, json: { required?: string[]; properties: Record<string, unknown> }) => {
|
||||
const checkLevel = (s: ParameterSchemaSpec, json: { required?: string[]; properties: Record<string, unknown> }) => {
|
||||
expect(new Set(json.required ?? [])).toEqual(new Set(requiredKeys(s)))
|
||||
for (const [key, prop] of Object.entries(s)) {
|
||||
const propJson = json.properties[key] as Record<string, unknown>
|
||||
if (prop.type === 'object' && prop.properties) {
|
||||
if ('type' in prop && prop.type === 'object' && prop.properties) {
|
||||
checkLevel(prop.properties, propJson as { required?: string[]; properties: Record<string, unknown> })
|
||||
}
|
||||
}
|
||||
}
|
||||
checkLevel(spec, schemaSpecToJsonSchema(spec))
|
||||
checkLevel(spec, parameterSchemaSpecToJsonSchema(spec))
|
||||
}))
|
||||
})
|
||||
|
||||
it('conversion is total (never throws) for any spec', () => {
|
||||
fc.assert(fc.property(specArb(3), (spec) => {
|
||||
expect(() => schemaSpecToJsonSchema(spec)).not.toThrow()
|
||||
expect(() => parameterSchemaSpecToJsonSchema(spec)).not.toThrow()
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
205
packages/core/tools/tests/schema.spec.ts
Normal file
205
packages/core/tools/tests/schema.spec.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { describe, expect, expectTypeOf, it } from 'vitest'
|
||||
import {
|
||||
JsonSchemaError,
|
||||
parameterSchemaSpecToJsonSchema,
|
||||
valueSchemaSpecToJsonSchema,
|
||||
type InferArgs,
|
||||
type InferValue,
|
||||
type JsonValue,
|
||||
type ParameterSchemaSpec,
|
||||
type ValueSchemaSpec,
|
||||
} from '../src/index.ts'
|
||||
|
||||
describe('the unified author schema DSL', () => {
|
||||
it('compiles every value root and the author-only json node', () => {
|
||||
expect(valueSchemaSpecToJsonSchema({ type: 'string', enum: ['a', 'b'], const: 'a' }))
|
||||
.toEqual({ type: 'string', enum: ['a', 'b'], const: 'a' })
|
||||
expect(valueSchemaSpecToJsonSchema({ type: 'number' })).toEqual({ type: 'number' })
|
||||
expect(valueSchemaSpecToJsonSchema({ type: 'integer' })).toEqual({ type: 'integer' })
|
||||
expect(valueSchemaSpecToJsonSchema({ type: 'boolean' })).toEqual({ type: 'boolean' })
|
||||
expect(valueSchemaSpecToJsonSchema({ type: 'null' })).toEqual({ type: 'null' })
|
||||
expect(valueSchemaSpecToJsonSchema({ type: 'array', items: { type: 'json' } }))
|
||||
.toEqual({ type: 'array', items: {} })
|
||||
expect(valueSchemaSpecToJsonSchema({ type: 'object', additionalProperties: false, properties: {} }))
|
||||
.toEqual({ type: 'object', additionalProperties: false, properties: {} })
|
||||
expect(valueSchemaSpecToJsonSchema({
|
||||
type: 'json',
|
||||
description: 'anything',
|
||||
title: 'Any JSON',
|
||||
default: null,
|
||||
examples: [{ nested: true }],
|
||||
})).toEqual({ description: 'anything', title: 'Any JSON', default: null, examples: [{ nested: true }] })
|
||||
expect(valueSchemaSpecToJsonSchema({ oneOf: [{ type: 'string' }, { type: 'null' }] }))
|
||||
.toEqual({ oneOf: [{ type: 'string' }, { type: 'null' }] })
|
||||
})
|
||||
|
||||
it('keeps the implicit parameter root open while preserving explicit object openness', () => {
|
||||
expect(parameterSchemaSpecToJsonSchema({
|
||||
closed: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: true,
|
||||
properties: { id: { type: 'integer', required: true } },
|
||||
},
|
||||
open: { type: 'object', additionalProperties: true },
|
||||
})).toEqual({
|
||||
type: 'object',
|
||||
properties: {
|
||||
closed: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: { id: { type: 'integer' } },
|
||||
required: ['id'],
|
||||
},
|
||||
open: { type: 'object', additionalProperties: true },
|
||||
},
|
||||
required: ['closed'],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects runtime-forged author forms rather than compiling them lossily', () => {
|
||||
for (const schema of [
|
||||
{ type: 'object' },
|
||||
{ oneOf: [{ type: 'string' }] },
|
||||
{ type: 'number', enum: ['1'] },
|
||||
{ type: 'string', enum: ['a'], const: 'b' },
|
||||
{ type: 'integer', const: 1.5 },
|
||||
{ type: 'json', default: undefined },
|
||||
{ type: 'array', items: { type: 'string', required: true } },
|
||||
{ type: 'array', items: 42 },
|
||||
{ type: 'string', extra: true },
|
||||
{ type: 'string', oneOf: [{ type: 'string' }, { type: 'null' }] },
|
||||
{ oneOf: 'not-an-array' },
|
||||
{ type: 'string', enum: 'a' },
|
||||
{},
|
||||
null,
|
||||
]) {
|
||||
expect(() => valueSchemaSpecToJsonSchema(schema as ValueSchemaSpec), JSON.stringify(schema)).toThrow(JsonSchemaError)
|
||||
}
|
||||
expect(() => parameterSchemaSpecToJsonSchema({
|
||||
value: { type: 'string', required: false },
|
||||
} as unknown as ParameterSchemaSpec)).toThrow(JsonSchemaError)
|
||||
expect(() => parameterSchemaSpecToJsonSchema(null as unknown as ParameterSchemaSpec)).toThrow(JsonSchemaError)
|
||||
expect(() => parameterSchemaSpecToJsonSchema({ bad: 42 } as unknown as ParameterSchemaSpec)).toThrow(JsonSchemaError)
|
||||
|
||||
const symbolKey = Symbol('hidden')
|
||||
expect(() => parameterSchemaSpecToJsonSchema({
|
||||
value: { type: 'string' },
|
||||
[symbolKey]: { type: 'number' },
|
||||
} as unknown as ParameterSchemaSpec)).toThrow(JsonSchemaError)
|
||||
const hiddenKey = Object.defineProperty({ value: { type: 'string' } }, 'hidden', {
|
||||
value: { type: 'number' },
|
||||
})
|
||||
expect(() => parameterSchemaSpecToJsonSchema(hiddenKey as ParameterSchemaSpec)).toThrow(JsonSchemaError)
|
||||
const sparseOneOf = new Array<ValueSchemaSpec>(2)
|
||||
sparseOneOf[0] = { type: 'string' }
|
||||
expect(() => valueSchemaSpecToJsonSchema({ oneOf: sparseOneOf } as unknown as ValueSchemaSpec)).toThrow(JsonSchemaError)
|
||||
const decoratedEnum = Object.assign(['a'], { hidden: true })
|
||||
expect(() => valueSchemaSpecToJsonSchema({
|
||||
type: 'string',
|
||||
enum: decoratedEnum,
|
||||
})).toThrow(JsonSchemaError)
|
||||
})
|
||||
|
||||
it('rejects cyclic author schemas', () => {
|
||||
const schema: Record<string, unknown> = { type: 'array' }
|
||||
schema.items = schema
|
||||
expect(() => valueSchemaSpecToJsonSchema(schema as unknown as ValueSchemaSpec)).toThrow(/circular/)
|
||||
|
||||
const properties: Record<string, unknown> = {}
|
||||
properties.self = { type: 'object', additionalProperties: true, properties }
|
||||
expect(() => parameterSchemaSpecToJsonSchema(properties as ParameterSchemaSpec)).toThrow(/circular/)
|
||||
})
|
||||
|
||||
it('compiles deeply nested author unions without using the JavaScript call stack', () => {
|
||||
const depth = 5_000
|
||||
let spec: unknown = { type: 'string' }
|
||||
for (let index = 0; index < depth; index++) spec = { oneOf: [spec, { type: 'null' }] }
|
||||
|
||||
const compiled = valueSchemaSpecToJsonSchema(spec as ValueSchemaSpec)
|
||||
|
||||
let cursor = compiled
|
||||
let layers = 0
|
||||
while (cursor.oneOf !== undefined) {
|
||||
cursor = cursor.oneOf[0]!
|
||||
layers++
|
||||
}
|
||||
expect(layers).toBe(depth)
|
||||
expect(cursor).toEqual({ type: 'string' })
|
||||
})
|
||||
|
||||
it('preserves a property literally named __proto__ as schema data', () => {
|
||||
const properties = Object.create(null) as ParameterSchemaSpec
|
||||
properties.__proto__ = { type: 'string', required: true }
|
||||
|
||||
const schema = parameterSchemaSpecToJsonSchema(properties)
|
||||
|
||||
expect(Object.hasOwn(schema.properties, '__proto__')).toBe(true)
|
||||
expect(schema.properties.__proto__).toEqual({ type: 'string' })
|
||||
expect(schema.required).toEqual(['__proto__'])
|
||||
})
|
||||
|
||||
it('infers scalar literals, arrays, objects, json, and exact-one unions', () => {
|
||||
expectTypeOf<InferValue<{ type: 'string'; enum: readonly ['a', 'b'] }>>().toEqualTypeOf<'a' | 'b'>()
|
||||
expectTypeOf<InferValue<{ type: 'number'; const: 1 }>>().toEqualTypeOf<1>()
|
||||
expectTypeOf<InferValue<{ type: 'integer' }>>().toEqualTypeOf<number>()
|
||||
expectTypeOf<InferValue<{ type: 'boolean'; enum: readonly [true] }>>().toEqualTypeOf<true>()
|
||||
expectTypeOf<InferValue<{ type: 'null' }>>().toEqualTypeOf<null>()
|
||||
expectTypeOf<InferValue<{ type: 'array'; items: { type: 'string' } }>>().toEqualTypeOf<string[]>()
|
||||
expectTypeOf<InferValue<{ type: 'array' }>>().toEqualTypeOf<JsonValue[]>()
|
||||
expectTypeOf<InferValue<{ type: 'json' }>>().toEqualTypeOf<JsonValue>()
|
||||
expectTypeOf<InferValue<{ oneOf: readonly [{ type: 'string' }, { type: 'null' }] }>>()
|
||||
.toEqualTypeOf<string | null>()
|
||||
expectTypeOf<InferValue<{
|
||||
type: 'object'
|
||||
additionalProperties: false
|
||||
properties: { id: { type: 'integer'; required: true }; label: { type: 'string' } }
|
||||
}>>().toEqualTypeOf<{ id: number; label?: string }>()
|
||||
expectTypeOf<InferValue<{
|
||||
type: 'object'
|
||||
additionalProperties: true
|
||||
properties: { id: { type: 'integer'; required: true } }
|
||||
}>>().toEqualTypeOf<{ id: number } & Record<string, JsonValue>>()
|
||||
})
|
||||
|
||||
it('bounds inference for deeply nested author schemas', () => {
|
||||
type Repeat<Count extends number, Result extends unknown[] = []> =
|
||||
Result['length'] extends Count ? Result : Repeat<Count, [unknown, ...Result]>
|
||||
type DeepArraySchema<Levels extends unknown[]> =
|
||||
Levels extends [unknown, ...infer Rest]
|
||||
? { type: 'array'; items: DeepArraySchema<Rest> }
|
||||
: { type: 'string' }
|
||||
type PeelArrays<Value, Levels extends unknown[]> =
|
||||
Levels extends [unknown, ...infer Rest]
|
||||
? Value extends (infer Item)[] ? PeelArrays<Item, Rest> : never
|
||||
: Value
|
||||
|
||||
type DeepValue = InferValue<DeepArraySchema<Repeat<50>>>
|
||||
expectTypeOf<PeelArrays<DeepValue, Repeat<16>>>().toEqualTypeOf<JsonValue>()
|
||||
})
|
||||
|
||||
it('infers required and optional parameter keys', () => {
|
||||
expectTypeOf<InferArgs<{
|
||||
path: { type: 'string'; required: true }
|
||||
offset: { type: 'integer' }
|
||||
data: { type: 'json' }
|
||||
}>>().toEqualTypeOf<{ path: string; offset?: number; data?: JsonValue }>()
|
||||
})
|
||||
|
||||
it('makes invalid author forms compile-time errors', () => {
|
||||
const symbolKey = Symbol('parameter')
|
||||
const invalidObjects = {
|
||||
// @ts-expect-error explicit object schemas require an openness decision
|
||||
object: { type: 'object' } satisfies ValueSchemaSpec,
|
||||
// @ts-expect-error oneOf requires at least two branches
|
||||
oneOf: { oneOf: [{ type: 'string' }] } satisfies ValueSchemaSpec,
|
||||
// @ts-expect-error scalar enum values must match the node type
|
||||
enum: { type: 'number', enum: ['1'] } satisfies ValueSchemaSpec,
|
||||
// @ts-expect-error parameter requiredness is true-or-absent
|
||||
required: { value: { type: 'string', required: false } } satisfies ParameterSchemaSpec,
|
||||
// @ts-expect-error parameter maps accept string keys only
|
||||
symbol: { [symbolKey]: { type: 'string' } } satisfies ParameterSchemaSpec,
|
||||
}
|
||||
expect(Object.keys(invalidObjects)).toHaveLength(5)
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,6 @@ import type { PreToolDecision, ToolDefinition, ToolExecution, ToolExecutionInput
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
@@ -39,7 +38,11 @@ function tool(name: string, reply = `ran:${name}`): ToolDefinition {
|
||||
name,
|
||||
description: `tool ${name}`,
|
||||
parameters: { type: 'object', properties: {} },
|
||||
execute: (): Promise<ContentBlock[]> => Promise.resolve([{ type: 'text', text: reply }]),
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args, value) => [{ type: 'text', text: value as string }],
|
||||
},
|
||||
execute: (): Promise<string> => Promise.resolve(reply),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,7 +227,7 @@ describe('scoped execution dispatch', () => {
|
||||
...tool('t'),
|
||||
execute: () => {
|
||||
bodyCalls += 1
|
||||
return Promise.resolve([{ type: 'text', text: 'ran:t' }])
|
||||
return Promise.resolve('ran:t')
|
||||
},
|
||||
})
|
||||
const guard = (execution: Readonly<ToolExecution>): string => {
|
||||
@@ -256,7 +259,7 @@ describe('scoped execution dispatch', () => {
|
||||
...tool('t'),
|
||||
execute: () => {
|
||||
bodyCalls += 1
|
||||
return Promise.resolve([])
|
||||
return Promise.resolve('ran:t')
|
||||
},
|
||||
})
|
||||
ctx.tools.guard(() => undefined)
|
||||
@@ -322,14 +325,14 @@ describe('scoped execution dispatch', () => {
|
||||
execute: (args) => {
|
||||
safeCalls += 1
|
||||
safeArguments = args
|
||||
return Promise.resolve([{ type: 'text', text: 'safe' }])
|
||||
return Promise.resolve('safe')
|
||||
},
|
||||
})
|
||||
ctx.tools.register({
|
||||
...tool('danger'),
|
||||
execute: () => {
|
||||
dangerCalls += 1
|
||||
return Promise.resolve([{ type: 'text', text: 'danger' }])
|
||||
return Promise.resolve('danger')
|
||||
},
|
||||
})
|
||||
scope.ctx.tools.guard(exec => exec.name === 'danger' ? 'danger denied' : undefined)
|
||||
@@ -382,7 +385,7 @@ describe('scoped execution dispatch', () => {
|
||||
...tool('t'),
|
||||
execute: () => {
|
||||
bodyCalls += 1
|
||||
return Promise.resolve([])
|
||||
return Promise.resolve('ran:t')
|
||||
},
|
||||
})
|
||||
ctx.on('tools/pre-execute', (_exec, next) => {
|
||||
@@ -444,7 +447,7 @@ describe('scoped execution dispatch', () => {
|
||||
...tool('t'),
|
||||
execute: (_args, exec) => {
|
||||
observed.push(exec.parent)
|
||||
return Promise.resolve([{ type: 'text', text: 'ran:t' }])
|
||||
return Promise.resolve('ran:t')
|
||||
},
|
||||
})
|
||||
ctx.on('tools/pre-execute', (exec, next) => {
|
||||
@@ -561,7 +564,7 @@ describe('scoped execution dispatch', () => {
|
||||
...tool('t'),
|
||||
execute: () => {
|
||||
bodyCalls += 1
|
||||
return Promise.resolve([])
|
||||
return Promise.resolve('ran:t')
|
||||
},
|
||||
})
|
||||
ctx.on('tools/pre-execute', (_exec, next) => {
|
||||
@@ -604,6 +607,7 @@ describe('scoped execution dispatch', () => {
|
||||
expect(result).toEqual({
|
||||
content: [{ type: 'text', text: 'ran:t' }],
|
||||
isError: false,
|
||||
value: 'ran:t',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -622,6 +626,7 @@ describe('scoped execution dispatch', () => {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'outer failure' }],
|
||||
isError: true,
|
||||
error: { message: 'outer failure' },
|
||||
}
|
||||
}, { prepend: true })
|
||||
scope.ctx.on('tools/result', (_exec, result) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { jsonSchemaToTs, renderToolsSdk } from '@deepseek-ai/dsh-tools/src/ts-types.ts'
|
||||
import { schemaSpecToJsonSchema } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { ToolSdkSchema } from '@deepseek-ai/dsh-tools/src/ts-types.ts'
|
||||
import { parameterSchemaSpecToJsonSchema } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
describe('jsonSchemaToTs', () => {
|
||||
it('maps the defineTool DSL subset', () => {
|
||||
it('maps every unified schema construct', () => {
|
||||
const cases: [unknown, string][] = [
|
||||
[{ type: 'string' }, 'string'],
|
||||
[{ type: 'number' }, 'number'],
|
||||
[{ type: 'integer' }, 'number'],
|
||||
[{ type: 'boolean' }, 'boolean'],
|
||||
[{ type: 'null' }, 'null'],
|
||||
[{ type: 'string', enum: ['a', 'b'] }, '"a" | "b"'],
|
||||
[{ type: 'number', enum: [1, 2] }, '1 | 2'],
|
||||
[{ type: 'integer', const: 2 }, '2'],
|
||||
[{ type: 'boolean', const: true }, 'true'],
|
||||
[{ type: 'null', const: null }, 'null'],
|
||||
[{ type: 'string', enum: ['a', 'b'], const: 'a' }, '"a"'],
|
||||
[{ oneOf: [{ type: 'string' }, { type: 'null' }] }, 'string | null'],
|
||||
[{ type: 'array', items: { type: 'number' } }, 'number[]'],
|
||||
[{ type: 'array', items: { type: 'string', enum: ['x', 'y'] } }, '("x" | "y")[]'],
|
||||
[{ type: 'array' }, 'unknown[]'],
|
||||
[{ type: 'object' }, 'Record<string, unknown>'],
|
||||
[{ type: 'object', properties: {} }, 'Record<string, unknown>'],
|
||||
[{ type: 'array' }, 'JsonValue[]'],
|
||||
[{ type: 'object' }, 'Record<string, JsonValue>'],
|
||||
[{ type: 'object', additionalProperties: false }, 'Record<string, never>'],
|
||||
[{ type: 'object', properties: {} }, 'Record<string, JsonValue>'],
|
||||
[{ type: 'object', properties: {}, additionalProperties: false }, 'Record<string, never>'],
|
||||
[{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: { id: { type: 'integer' }, label: { type: 'string' } },
|
||||
required: ['id'],
|
||||
}, ['{', ' id: number;', ' label?: string;', '}'].join('\n')],
|
||||
[{}, 'JsonValue'],
|
||||
]
|
||||
for (const [schema, expected] of cases) {
|
||||
expect(jsonSchemaToTs(schema), JSON.stringify(schema)).toBe(expected)
|
||||
@@ -22,11 +39,12 @@ describe('jsonSchemaToTs', () => {
|
||||
})
|
||||
|
||||
it('renders objects with required/optional keys, nested shapes, and per-property docs', () => {
|
||||
const schema = schemaSpecToJsonSchema({
|
||||
const schema = parameterSchemaSpecToJsonSchema({
|
||||
path: { type: 'string', required: true, description: 'Absolute file path' },
|
||||
limit: { type: 'number' },
|
||||
opts: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
properties: { deep: { type: 'boolean', required: true } },
|
||||
},
|
||||
})
|
||||
@@ -37,8 +55,8 @@ describe('jsonSchemaToTs', () => {
|
||||
' limit?: number;',
|
||||
' opts?: {',
|
||||
' deep: boolean;',
|
||||
' };',
|
||||
'}',
|
||||
' } & Record<string, JsonValue>;',
|
||||
'} & Record<string, JsonValue>',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
@@ -48,9 +66,6 @@ describe('jsonSchemaToTs', () => {
|
||||
null,
|
||||
42,
|
||||
'string-schema',
|
||||
{},
|
||||
{ type: 'integer' },
|
||||
{ type: 'null' },
|
||||
{ oneOf: [{ type: 'string' }] },
|
||||
{ $ref: '#/defs/x' },
|
||||
{ type: 'object', properties: 7 },
|
||||
@@ -61,18 +76,13 @@ describe('jsonSchemaToTs', () => {
|
||||
for (const schema of cases) {
|
||||
expect(() => jsonSchemaToTs(schema), JSON.stringify(schema)).not.toThrow()
|
||||
}
|
||||
expect(jsonSchemaToTs({ type: 'integer' })).toBe('unknown')
|
||||
expect(jsonSchemaToTs({ oneOf: [] })).toBe('unknown')
|
||||
expect(jsonSchemaToTs({ type: 'object', properties: 7 })).toBe('Record<string, unknown>')
|
||||
expect(jsonSchemaToTs({ type: 'object', properties: { bad: { $ref: 'x' } }, required: ['bad'] })).toContain('bad: unknown;')
|
||||
// A non-string-only enum degrades to plain string; an empty one too.
|
||||
expect(jsonSchemaToTs({ type: 'string', enum: [1, 2] })).toBe('string')
|
||||
expect(jsonSchemaToTs({ type: 'string', enum: [] })).toBe('string')
|
||||
// A hostile required list only accepts string members.
|
||||
expect(jsonSchemaToTs({ type: 'object', properties: { a: { type: 'string' } }, required: [7] })).toContain('a?: string;')
|
||||
// A property VALUE that is not an object degrades to unknown (and can
|
||||
// carry no description).
|
||||
expect(jsonSchemaToTs({ type: 'object', properties: { weird: 42 } })).toContain('weird?: unknown;')
|
||||
expect(jsonSchemaToTs({ type: 'object', properties: 7 })).toBe('unknown')
|
||||
expect(jsonSchemaToTs({ type: 'object', properties: { bad: { $ref: 'x' } }, required: ['bad'] })).toBe('unknown')
|
||||
expect(jsonSchemaToTs({ type: 'string', enum: [1, 2] })).toBe('unknown')
|
||||
expect(jsonSchemaToTs({ type: 'string', enum: [] })).toBe('unknown')
|
||||
expect(jsonSchemaToTs({ type: 'object', properties: { a: { type: 'string' } }, required: [7] })).toBe('unknown')
|
||||
expect(jsonSchemaToTs({ type: 'object', properties: { weird: 42 } })).toBe('unknown')
|
||||
})
|
||||
|
||||
it('escapes a comment-closer inside a description so the generated JSDoc cannot end early', () => {
|
||||
@@ -83,33 +93,59 @@ describe('jsonSchemaToTs', () => {
|
||||
expect(rendered).not.toContain('tool-*/ over')
|
||||
expect(rendered).toContain(String.raw`tool-*\/ over`)
|
||||
})
|
||||
|
||||
it('renders deeply nested unions without using the JavaScript call stack', () => {
|
||||
const depth = 5_000
|
||||
let schema: unknown = { type: 'string' }
|
||||
for (let index = 0; index < depth; index++) schema = { oneOf: [schema, { type: 'null' }] }
|
||||
|
||||
const rendered = jsonSchemaToTs(schema)
|
||||
|
||||
expect(rendered.startsWith('string | null')).toBe(true)
|
||||
expect(rendered.length).toBe('string'.length + depth * ' | null'.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderToolsSdk', () => {
|
||||
const bash: ToolSchema = {
|
||||
const bash: ToolSdkSchema = {
|
||||
name: 'bash',
|
||||
description: 'Run a shell command.',
|
||||
parameters: schemaSpecToJsonSchema({ command: { type: 'string', required: true } }) as unknown as Record<string, unknown>,
|
||||
parameters: parameterSchemaSpecToJsonSchema({ command: { type: 'string', required: true } }) as unknown as Record<string, unknown>,
|
||||
output: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: { exitCode: { type: 'integer' } },
|
||||
required: ['exitCode'],
|
||||
},
|
||||
}
|
||||
const exotic: ToolSchema = {
|
||||
const exotic: ToolSdkSchema = {
|
||||
name: 'my-mcp.tool',
|
||||
description: 'Exotic name.',
|
||||
parameters: schemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
|
||||
parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
|
||||
output: { type: 'array', items: { type: 'string' } },
|
||||
}
|
||||
|
||||
it('declares every tool in lexicographic order with quoted keys for exotic names', () => {
|
||||
const text = renderToolsSdk([exotic, bash])
|
||||
expect(text).toContain('interface ToolArgsMap {')
|
||||
expect(text).toContain('interface ToolOutputMap {')
|
||||
expect(text).toContain('type ToolName = keyof ToolOutputMap')
|
||||
expect(text).toContain('declare class ToolCallError extends Error')
|
||||
expect(text).toContain('readonly toolName: ToolName;')
|
||||
expect(text).toContain('declare const tools: {')
|
||||
expect(text.indexOf('bash(args:')).toBeGreaterThan(0)
|
||||
expect(text).toContain('"my-mcp.tool"(args:')
|
||||
expect(text.indexOf('bash(args:')).toBeLessThan(text.indexOf('"my-mcp.tool"(args:'))
|
||||
expect(text).toContain('): Promise<string>;')
|
||||
expect(text).toContain('type JsonValue = null | boolean | number | string')
|
||||
expect(text.indexOf('bash: {')).toBeGreaterThan(0)
|
||||
expect(text).toContain('"my-mcp.tool":')
|
||||
expect(text.indexOf('bash:')).toBeLessThan(text.indexOf('"my-mcp.tool":'))
|
||||
expect(text).toContain('exitCode: number;')
|
||||
expect(text).toContain('"my-mcp.tool": string[];')
|
||||
expect(text).toContain('[K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]>;')
|
||||
expect(text).toContain('/** Run a shell command. */')
|
||||
// The fixed instruction lines the model relies on.
|
||||
expect(text).toContain('erasable syntax only')
|
||||
expect(text).toContain('rejects with an `Error`')
|
||||
expect(text).toContain('rejects with `ToolCallError`')
|
||||
expect(text).toContain('sequentially, even under `Promise.all`')
|
||||
expect(text).toContain('JSON-serializable')
|
||||
expect(text).toContain('lossless JSON')
|
||||
})
|
||||
|
||||
it('is deterministic: same tool set, byte-identical text regardless of input order', () => {
|
||||
@@ -119,6 +155,8 @@ describe('renderToolsSdk', () => {
|
||||
})
|
||||
|
||||
it('renders an empty declaration for an empty tool set', () => {
|
||||
expect(renderToolsSdk([])).toContain('declare const tools: {}')
|
||||
const text = renderToolsSdk([])
|
||||
expect(text).toContain('interface ToolArgsMap {}')
|
||||
expect(text).toContain('interface ToolOutputMap {}')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user