Merge remote-tracking branch 'origin/master' into worktree/ci-subminute-stability

This commit is contained in:
Tianyi Cui
2026-07-23 18:33:04 +08:00
265 changed files with 13746 additions and 3455 deletions

View File

@@ -49,6 +49,8 @@ The overlay is computed from the current `ToolExecution` and passed through the
Result text contains stdout, an optional `[stderr]` section, then applicable sandbox-denial, timeout, signal, exit-code, and truncation markers. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Truncation links a safe complete spill file or reports it unavailable. Only infrastructure failures such as spawn errors and aborts produce `isError`.
The canonical success is `{ kind: 'foreground', ...BashRunResult }` for a completed foreground process or `{ kind: 'background', taskId }` for a published task. The Native renderer preserves the text above, including exactly `started background task <id>`; programmatic consumers use the typed fields without parsing those strings. Executor stream caps remain acquisition limits on `BashRunResult` and carry their spill paths.
When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` before spawning, registers the calling agent as owner, and adapts the returned `BashProcess` handle into generic cancel/done/incremental-output hooks. The task runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps bash exit/sandbox facts into task output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time.
## UI presentation

View File

@@ -11,8 +11,9 @@
import { Service, type Context } from 'cordis'
import z from 'schemastery'
import { isAbsolute, resolve as resolvePath } from 'node:path'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools'
import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-session-persistence'
import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -22,7 +23,7 @@ import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandb
import { ESCALATION_TARGETS, approveEscalation, canonicalPath, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox'
import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash'
import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash'
import type { BashRunResult, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash'
import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-paths'
import { processOutcome } from './background.ts'
import { parseExitStatus, renderProcessRead, renderResult } from './render.ts'
@@ -206,7 +207,7 @@ export class BashEnvRegistry extends Service {
}
}
/** Parsed tool args; execute validates value constraints absent from SchemaSpec. */
/** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */
interface BashToolArgs {
command: string
description: string
@@ -318,6 +319,38 @@ function resolveWorkdir(
return modelWorkdir
}
/** Detach the executor DTO from readonly seam interfaces into plain JSON data. */
function canonicalBashResult(result: BashRunResult) {
const output = (stream: BashRunResult['stdout']) => ({
text: stream.text,
truncated: stream.truncated,
...stream.spillPath !== undefined ? { spillPath: stream.spillPath } : {},
})
return {
exitCode: result.exitCode,
signal: result.signal,
timedOut: result.timedOut,
aborted: result.aborted,
timeoutMs: result.timeoutMs,
stdout: output(result.stdout),
stderr: output(result.stderr),
...result.sandbox !== undefined ? {
sandbox: {
mode: result.sandbox.mode,
denied: result.sandbox.denied,
...result.sandbox.enforcement !== undefined ? { enforcement: result.sandbox.enforcement } : {},
...result.sandbox.runnerFailed !== undefined ? { runnerFailed: result.sandbox.runnerFailed } : {},
},
} : {},
}
}
/** Canonical background-handle properties shared by the bash output union. */
const BACKGROUND_OUTPUT_PROPERTIES = {
kind: { type: 'string', required: true, const: 'background' },
taskId: { type: 'string', required: true },
} as const
export function apply(ctx: Context, config: Config = {}): void {
const bashEnv = new BashEnvRegistry(ctx, config)
bashEnv.register({
@@ -415,6 +448,65 @@ export function apply(ctx: Context, config: Config = {}): void {
},
} : {},
},
output: {
schema: {
oneOf: [
{
type: 'object',
additionalProperties: false,
properties: BACKGROUND_OUTPUT_PROPERTIES,
},
{
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, const: 'foreground' },
exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] },
signal: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] },
timedOut: { type: 'boolean', required: true },
aborted: { type: 'boolean', required: true },
timeoutMs: { type: 'number', required: true },
stdout: {
type: 'object',
additionalProperties: false,
required: true,
properties: {
text: { type: 'string', required: true },
truncated: { type: 'boolean', required: true },
spillPath: { type: 'string' },
},
},
stderr: {
type: 'object',
additionalProperties: false,
required: true,
properties: {
text: { type: 'string', required: true },
truncated: { type: 'boolean', required: true },
spillPath: { type: 'string' },
},
},
sandbox: {
type: 'object',
additionalProperties: false,
properties: {
mode: { type: 'string', required: true },
denied: { type: 'boolean', required: true },
enforcement: { type: 'string' },
runnerFailed: { type: 'boolean' },
},
},
},
},
],
},
render: (_args, value) => [{
type: 'text',
text: value.kind === 'background'
? `started background task ${value.taskId}`
: renderResult(value as { kind: 'foreground' } & BashRunResult, escalationModes),
}],
},
async execute(args: BashToolArgs, exec) {
validateBashArgs(args)
// Description is display metadata; workdir defaults to the caller's session.
@@ -444,7 +536,11 @@ export function apply(ctx: Context, config: Config = {}): void {
throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
}
// The caller owns cancellation until TaskService commits detached ownership.
if (exec.signal.aborted) return []
if (exec.signal.aborted) {
const error = new HarnessError('tool call aborted', TOOL_ABORTED)
error.name = 'AbortError'
throw error
}
// Task preflight finishes before the starter can spawn a process.
const id = tasks.start({
kind: 'bash',
@@ -459,14 +555,14 @@ export function apply(ctx: Context, config: Config = {}): void {
}
},
})
return [{ type: 'text', text: `started background task ${id}` }]
return { kind: 'background' as const, taskId: id }
}
const result = await ctx.bash.run(ctx.bash.resolve({
...request,
signal: exec.signal,
}))
if (result.aborted) throw new Error('command aborted')
return [{ type: 'text', text: renderResult(result, escalationModes) }]
return { kind: 'foreground' as const, ...canonicalBashResult(result) }
},
presentCall: presentBashCall,
presentResult: presentBashResult,

View File

@@ -122,7 +122,13 @@ class RecordingSandboxExecutor extends BashExecutor {
timeoutMs: spec.timeoutMs,
stdout: { text: 'ok', truncated: false },
stderr: { text: '', truncated: false },
sandbox: { mode: spec.sandboxPolicy?.mode ?? 'read-only', denied: false },
sandbox: {
mode: spec.sandboxPolicy?.mode ?? 'read-only',
denied: false,
...spec.command === 'without optional sandbox facts'
? {}
: { enforcement: 'full' as const, runnerFailed: false },
},
})
}
@@ -213,6 +219,16 @@ describe('bash tool', () => {
const ctx = await setup()
const result = await call(ctx, 'bash', { command: 'echo hello', description: 'test command' })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected bash success')
expect(result.value).toMatchObject({
kind: 'foreground',
exitCode: 0,
signal: null,
timedOut: false,
aborted: false,
stdout: { text: 'hello\n', truncated: false },
stderr: { text: '', truncated: false },
})
expect(text(result)).toBe('hello\n')
})
@@ -296,7 +312,7 @@ describe('bash tool', () => {
})
// Type and required-key violations are rejected by the harness
// (defineTool validates against the SchemaSpec — the arg-validation Agent Note) before execute.
// (defineTool validates against the ParameterSchemaSpec — the arg-validation Agent Note) before execute.
it.each([
[{}, /missing required property "command"/],
[{ command: 42, description: 'd' }, /"command" must be a string/],
@@ -312,7 +328,7 @@ describe('bash tool', () => {
expect(text(result)).toMatch(pattern)
})
// Value constraints the SchemaSpec can't express stay in the tool body.
// Value constraints the ParameterSchemaSpec can't express stay in the tool body.
it.each([
[{ command: ' ', description: 'd' }, /invalid command/],
[{ command: 'x', description: ' ' }, /invalid description/],
@@ -408,6 +424,8 @@ describe('background execution through the task runtime', () => {
const ctx = await setupWithTasks()
const started = await call(ctx, 'bash', { command: 'echo bg-ok', description: 'test command', run_in_background: true })
expect(started.isError).toBe(false)
if (started.isError) throw new Error('expected background bash success')
expect(started.value).toEqual({ kind: 'background', taskId: 'bash-1' })
expect(text(started)).toBe('started background task bash-1')
const read = await callUntilText(ctx, 'task_output', { task_id: 'bash-1' }, 'bg-ok')
@@ -479,7 +497,10 @@ describe('background execution through the task runtime', () => {
signal: controller.signal,
})
expect(result.isError).toBe(true)
expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH })
expect(result.error).toEqual({
message: 'tool call aborted before dispatch',
info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
})
expect(text(result)).toBe('Error: tool call aborted before dispatch')
expect((ctx.bash as CountingStartExecutor).starts).toBe(0)
})
@@ -633,7 +654,10 @@ describe('sandbox escalation through the generic task producer', () => {
signal: controller.signal,
})
expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED })
expect(result.error).toEqual({
message: 'tool call aborted',
info: { name: 'AbortError', code: TOOL_ABORTED },
})
expect(text(result)).toBe('Error: tool call aborted')
expect(start).not.toHaveBeenCalled()
})
@@ -647,6 +671,22 @@ describe('sandbox escalation through the generic task producer', () => {
expect(bash.modes).toEqual(['workspace-write', 'danger-full-access'])
})
it('omits sandbox facts the executor did not acquire from the canonical result', async () => {
const { ctx } = await setupSandboxed()
const result = await call(ctx, 'bash', {
command: 'without optional sandbox facts',
description: 'exercise optional sandbox facts',
})
if (result.isError) throw new Error('expected foreground bash success')
expect(result.value).toMatchObject({
kind: 'foreground',
sandbox: { mode: 'read-only', denied: false },
})
expect((result.value as { sandbox: object }).sandbox).not.toHaveProperty('enforcement')
expect((result.value as { sandbox: object }).sandbox).not.toHaveProperty('runnerFailed')
})
it('keeps the exhaustiveness backstop for a rogue approval implementation', async () => {
const { ctx } = await setupSandboxed(true)
ctx.approval.request = () => Promise.resolve('rogue' as ApprovalOutcome)

View File

@@ -10,32 +10,33 @@ Worker-thread implementation of the [`@deepseek-ai/dsh-code-runtime`](../code-ru
config:
computeMs: 60000 # busy-time budget (measured event-loop active time)
maxWallMs: 600000 # wall-clock ceiling; never pauses for anything
maxLogBytes: 65536 # shared byte budget for captured log text
maxValueBytes: 32768 # rendered-completion-value cap
maxOutputBytes: 67108864 # combined serialized outer-output cap (64 MiB)
maxOldGenerationSizeMb: 512 # worker heap cap (resourceLimits)
```
Every field is validated (positive numbers) and defaulted; there are no other tunables.
Every field is validated and defaulted; `maxOutputBytes` is a safe integer of at least four bytes, the remaining fields are positive finite numbers, and there are no other tunables.
## Design
- **One fresh worker per run, no pooling** — a program's world dies with its worker: no cross-run state to log, state bleed unrepresentable, runs reconstructable from the session log alone.
- **Type-strip host-side, in execution context** — the program is wrapped in an async-function shell, stripped with `node:module`'s `stripTypeScriptTypes` (erasable syntax only — `enum`/namespaces are rejected as a program `exception` and no worker spawns), and sliced back out byte-positioned; it then executes as the body of an `AsyncFunction`, so top-level `await`/`return` work.
- **The port assumes a hostile peer** — model code can reach `parentPort` and forge traffic, so every inbound message is shape-validated and REBUILT before anything reads it (`null`, primitives, junk types, and malformed payloads drop without a throw; forged extra fields never ride along), the host answers each call id at most once, resolves binding names as OWN properties only (a forged `constructor` cannot walk a prototype chain), drops post-settlement replies, and converts a non-cloneable binding resolution into an error reply. Forged `log`/`done` messages cannot bypass the caps: one host-side ledger bounds everything that lands in `logs`, and the completion value is re-capped host-side. Worker-side namespaces are null-prototype with `defineProperty`, so `__proto__`-shaped binding names are ordinary keys.
- **The port assumes a hostile peer** — model code can reach `parentPort` and forge traffic, so every inbound message is shape-validated and REBUILT before anything reads it (`null`, primitives, junk types, and malformed payloads drop without a throw; forged extra fields never ride along), the host answers each call id at most once, resolves binding names as OWN properties only (a forged `constructor` cannot walk a prototype chain), drops post-settlement replies, and validates every binding resolution and completion as lossless JSON. Forged `log`/`done` messages cannot bypass the outer cap: the host repeats validation and accounts every admitted log plus the completion or diagnostic. Worker-side namespaces are null-prototype with `defineProperty`, so `__proto__`-shaped binding names are ordinary keys.
- **Binding rejection classes are request data** — an optional namespace descriptor names the constructor global and the own property that receives the failed member name. The worker materializes and injects that real class, so `instanceof` works without hardcoding `tools` or `ToolCallError`; declarations with invalid or colliding globals fail before a worker spawns. Failures use module-captured error and property-definition intrinsics plus null-prototype descriptors, so later model mutations cannot turn a rejected binding into a worker crash.
- **Two independent budgets, because the peer is hostile** — `computeMs` meters the worker's MEASURED busy time (`worker.performance.eventLoopUtilization()` polling): a hot loop cannot hide behind a pending decoy dispatch, and a program awaiting a slow tool accrues nothing. `maxWallMs` backstops what busy time cannot see (awaiting a promise nobody resolves). Both funnel into `worker.terminate()`, which ends hot synchronous loops too; heap overflow surfaces as the worker's OOM exit (`kind: 'worker-exit'`).
- **Logs stream eagerly** — console/stdout/stderr text crosses the port in emission order, so a timed-out or killed program still shows what it printed. ONE shared `maxLogBytes` ledger bounds everything: streamed text, forged port traffic, and pipe bytes that bypass the patched streams (appended after), with the overflow marked in-band once.
- **Intermediate binding values are complete JSON** — binding arguments and resolutions undergo iterative lossless-JSON validation. Before program execution, the worker captures its own realm's plain-container prototype identities plus the native function-source check used only for foreign realms, so constructor-slot mutation and user-authored impostors cannot change container classification. It also captures every structural and metering intrinsic used by this JSON boundary, creates property descriptors without a prototype, and bypasses mutable collection prototypes for private traversal state; model mutations of globals, prototype methods, or descriptor-shaped `Object.prototype` fields therefore cannot alter validation, wire transport, or byte accounting. Values flatten into a bounded-depth pre-order wire value for structured clone and rebuild iteratively on the other side. They have no byte, JavaScript call-stack, or nested structured-clone depth cap. They never enter the outer-output ledger or model context; provider/executor acquisition bounds and process/worker memory remain the limits.
- **Logs stream eagerly into one outer ledger** — console/stdout/stderr text crosses the port in emission order, so a timed-out or killed program still shows what it printed. The worker charges exact JSON-string bytes and preflights completion values and exception diagnostics against the remaining combined budget before posting them; a thrown million-byte stack therefore becomes the fixed `output-limit` diagnostic at the worker boundary. Native writes that bypass the patched stream slots arrive on pipes independent of the completion port, so the host repeats the ledger for those bytes and hostile forged traffic; settlement continues bounded pipe capture until worker termination completes before materializing the result. `maxOutputBytes` accounts the JSON serialization of the outer `logs` array plus the completion value or failure-message payload; fixed `CodeRunResult` field names, braces, the bounded error-kind tag, and later presentation whitespace are outside that variable-payload ledger. At or below the cap the exact value returns; a lossy completion is `invalid-output`, and a combined overflow is `output-limit` rather than a substituted inspected string. The failure retains the fitting captured prefix and later follows the normal outer `run_code` spill policy.
- **Empty environment** — the worker gets `env: {}` and `execArgv: []`: no ambient credentials (stronger than the scrubbed-env rule for spawned commands) and no inherited loader flags.
- **Dispose to quiescence** — teardown fails in-flight runs as `abort` and AWAITS each worker's exit before resolving.
## The worker entry, unbuilt and built
Source mode loads erasable-only `src/worker.ts` through Node's native type stripping. Built mode passes the sibling `lib/worker.cjs` as a filesystem path because pkg's VFS Worker hook expects CommonJS; the same path works under ordinary Node. `tests/built-lib.e2e.ts` pins the real load path required by [docs/testing.md](../../../docs/testing.md).
Source mode loads erasable-only `src/worker.ts` through Node's native type stripping. Its transitive runtime closure contains only Node built-ins and relative source modules, so a fresh checkout never requires a sibling workspace package's unbuilt `lib/` export. The worker-local JSON snapshotter is parity-tested against the session-owned canonical boundary; both sides flatten and rebuild validated values around the message port so application nesting never reaches structured clone. Built mode passes the sibling `lib/worker.cjs` as a filesystem path because pkg's VFS Worker hook expects CommonJS; the same path works under ordinary Node. `tests/built-lib.e2e.ts` pins the real load path required by [docs/testing.md](../../../docs/testing.md).
The SDK surface is the default/named `WorkerCodeRuntime` class plus `Config`. The operational `./worker` subpath exists only as the packaged spawn entry; the wire protocol and bootstrap helpers are source-private implementation details.
## Model Experience
Indirectly, through Code Mode in [`dsh-tools`](../../core/tools/README.md), which renders this worker's capped printed or returned data and exact `[dsh-code-runtime-worker] log capture truncated at <maxLogBytes> bytes` and `… [truncated]` markers into a retained `run_code` result. Binding traffic and worker internals stay outside context.
Indirectly, through Code Mode in [`dsh-tools`](../../core/tools/README.md), which renders the exact outer value when it fits or an explicit `invalid-output` / `output-limit` failure. Only the outer `run_code` result enters model context and its ordinary spill policy; binding traffic and intermediate values remain execution-local.
#### KV Cache effect
@@ -47,4 +48,5 @@ No direct invalidation; the named consumer owns any request-prefix changes.
- **Type-strip rides Node's experimental `stripTypeScriptTypes` API** — the relied-on behavior is pinned by unit tests, with amaro/sucrase as named drop-in replacements if it shifts.
- **`computeMs` expiry can overshoot by up to one poll interval** — busy time is sampled every 25 ms (an internal constant, deliberately not config).
- **Programs get a five-method `console` shim** (`log`/`info`/`warn`/`error`/`debug`) — deliberately not Node's full console surface.
- **A non-cloneable or oversize completion value does not cross as a value** — it arrives as a bounded, truncation-marked `util.inspect` rendering in `value`'s place.
- **Intermediate binding values have no byte cap** — a program can exhaust process or worker memory with a value that never becomes outer output.
- **The 64 MiB default is a rejection boundary, not recoverable storage** — outer spill can save only the bounded logs and diagnostic returned after `output-limit`; bytes rejected beyond the runtime cap never reach the spill layer.

View File

@@ -33,6 +33,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-code-runtime": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
@@ -41,6 +42,7 @@
"devDependencies": {
"@deepseek-ai/dsh-code-runtime": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -6,9 +6,21 @@
*/
import { inspect } from 'node:util'
import { serialize } from 'node:v8'
import { logTruncationMarker } from './protocol.ts'
import type { DoneMessage, ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from './output-json.ts'
import { decodeWorkerJson, encodeWorkerJson, snapshotCodeJsonValue } from './worker-json.ts'
const CapturedError = Error
const capturedObjectCreate = Object.create
const capturedObjectDefineProperty = Object.defineProperty
/** Define one public binding-error field without consulting mutable globals or descriptor prototypes. */
function defineBindingErrorField(error: Error, key: string, value: string): void {
const attributes = capturedObjectCreate(null) as PropertyDescriptor
attributes.enumerable = true
attributes.value = value
capturedObjectDefineProperty(error, key, attributes)
}
/** The port surface the bootstrap needs — satisfied by `parentPort` and by the tests' fake. */
export interface BootstrapPort {
@@ -27,26 +39,28 @@ export interface PatchableStream {
}
/**
* Ordered text capture under one shared byte budget, delivered to a sink as
* each item lands (the real sink streams text over the port eagerly, so
* captured output survives a mid-run termination). Once the budget is
* exhausted it emits exactly one in-band marker and silently drops everything
* after. The cap is a blast-radius bound, so "how much was lost" intentionally
* stays unmeasured.
* Ordered text capture under the shared outer JSON-byte budget, delivered to
* a sink as each item lands (the real sink streams text over the port eagerly,
* so captured output survives a mid-run termination). It includes the log
* array syntax and string escaping in its accounting. Once exhausted it emits
* the fitting prefix and reports the limit once; the host turns that condition
* into an explicit `output-limit` run failure.
*/
export class LogBuffer {
private remaining: number
private bytes = 2 // JSON serialization of the empty logs array: []
private entries = 0
private truncated = false
// Explicit fields, not constructor parameter properties: this module loads
// under Node's native strip-only mode, which rejects non-erasable syntax —
// and parameter properties are non-erasable.
private readonly maxBytes: number
private readonly sink: (text: string) => void
private readonly onLimit: () => void
private readonly maxBytes: number
constructor(maxBytes: number, sink: (text: string) => void) {
constructor(maxBytes: number, sink: (text: string) => void, onLimit: () => void = () => {}) {
this.maxBytes = maxBytes
this.sink = sink
this.remaining = maxBytes
this.onLimit = onLimit
}
/**
@@ -55,15 +69,32 @@ export class LogBuffer {
*/
push(text: string): void {
if (this.truncated) return
const cost = Buffer.byteLength(text, 'utf8')
if (cost > this.remaining) {
const separatorBytes = this.entries > 0 ? 1 : 0
const availableBytes = this.maxBytes - this.bytes - separatorBytes
const stringBytes = jsonStringBytesUpTo(text, availableBytes)
if (stringBytes === undefined) {
this.truncated = true
this.sink(logTruncationMarker(this.maxBytes))
const prefix = truncateJsonStringBytes(text, availableBytes)
if (prefix.length > 0) {
const prefixBytes = jsonStringBytesUpTo(prefix, availableBytes)
/* v8 ignore next -- truncateJsonStringBytes guarantees the returned prefix fits. */
if (prefixBytes === undefined) throw new CapturedError('worker output ledger produced an oversized log prefix')
this.bytes += prefixBytes + separatorBytes
this.entries += 1
this.sink(prefix)
}
this.onLimit()
return
}
this.remaining -= cost
this.bytes += stringBytes + separatorBytes
this.entries += 1
this.sink(text)
}
/** Remaining exact JSON-byte budget for the completion value or failure message. */
remainingOutputBytes(): number {
return this.maxBytes - this.bytes
}
}
/** The five console methods the shim captures, in the seam's level vocabulary. */
@@ -122,59 +153,79 @@ export function captureStreamWrites(logs: LogBuffer, stream: PatchableStream): (
const INSPECT_OPTIONS = { depth: 4, maxArrayLength: 100, maxStringLength: 10_000 } as const
/**
* The longest prefix of `text` whose UTF-8 encoding fits `maxBytes`, cut at
* a code-point boundary (never mid-surrogate-pair). The byte caps are BYTE
* caps — `String.prototype.slice` counts UTF-16 code units, up to 3× smaller
* than what a multibyte string actually costs across the boundary.
* @param text - the string to bound.
* @param maxBytes - the UTF-8 byte budget the prefix must fit.
* @returns the prefix (all of `text` when it already fits).
* Prepare the program's completion value for the done message. Only lossless
* JSON crosses, and a value that does not fit the remaining combined outer
* budget reports `output-limit`; the host revalidates hostile traffic and
* remains authoritative for native pipe writes the worker cannot observe.
*
* @param value - the program's completion value.
* @param remainingOutputBytes - exact bytes left after captured logs.
* @param maxOutputBytes - the configured cap named in an overflow diagnostic.
* @returns the done-message fragment: `{}` for `undefined`, else a flat wire `{ value }`.
*/
export function truncateUtf8Bytes(text: string, maxBytes: number): string {
if (Buffer.byteLength(text, 'utf8') <= maxBytes) return text
let bytes = 0
let end = 0
for (const char of text) {
const cost = Buffer.byteLength(char, 'utf8')
if (bytes + cost > maxBytes) break
bytes += cost
end += char.length
export function prepareCompletion(
value: unknown,
remainingOutputBytes: number,
maxOutputBytes: number = remainingOutputBytes,
): Omit<DoneMessage, 'type'> {
if (value === undefined) return {}
let snapshot: ReturnType<typeof snapshotCodeJsonValue>
try {
snapshot = snapshotCodeJsonValue(value)
} catch {
snapshot = undefined
}
return text.slice(0, end)
if (snapshot === undefined) {
return prepareFailure(
'invalid-output',
'program completion must be lossless JSON',
remainingOutputBytes,
maxOutputBytes,
)
}
if (jsonValueBytesUpTo(snapshot, remainingOutputBytes) === undefined) {
return outputLimit(maxOutputBytes)
}
return { value: encodeWorkerJson(snapshot) }
}
/** Build the fixed overflow fragment without carrying rejected variable bytes. */
function outputLimit(maxOutputBytes: number): Omit<DoneMessage, 'type'> {
return { error: { kind: 'output-limit', message: `outer output exceeded ${maxOutputBytes} bytes` } }
}
/** Admit one bounded failure message or replace it with the fixed overflow diagnostic. */
function prepareFailure(
kind: 'exception' | 'invalid-output',
message: string,
remainingOutputBytes: number,
maxOutputBytes: number,
): Omit<DoneMessage, 'type'> {
if (jsonStringBytesUpTo(message, remainingOutputBytes) === undefined) return outputLimit(maxOutputBytes)
return { error: { kind, message } }
}
/**
* Prepare the program's completion value for the done message: a value whose MEASURED
* cross-boundary size fits `maxValueBytes` crosses raw — exact bytes for a string, the
* structured-clone wire size (`v8.serialize`) for everything else, so a huge container whose
* bounded inspect rendering happens to be small cannot smuggle itself past the cap. Oversized
* or non-cloneable values are replaced by a bounded string rendering with an in-band marker.
*
* @param value - the program's completion value.
* @param maxValueBytes - the byte cap for the value.
* @returns the done-message fragment: `{}` for `undefined`, else `{ value }`.
* Prepare a thrown program value without sending an unbounded stack or
* string across the worker port.
* @param error - the value thrown by the program.
* @param remainingOutputBytes - exact bytes left after captured logs.
* @param maxOutputBytes - the configured cap named in an overflow diagnostic.
* @returns a bounded exception or fixed output-limit fragment.
*/
export function prepareValue(value: unknown, maxValueBytes: number): { value?: unknown } {
if (value === undefined) return {}
if (typeof value === 'string') {
if (Buffer.byteLength(value, 'utf8') <= maxValueBytes) return { value }
} else {
let size: number | undefined
try {
size = serialize(value).byteLength
} catch {
// Only the verdict matters: the value has parts the structured-clone
// algorithm rejects (functions, classes, …) and must cross as its
// rendering instead.
size = undefined
}
if (size !== undefined && size <= maxValueBytes) return { value }
export function prepareException(
error: unknown,
remainingOutputBytes: number,
maxOutputBytes: number = remainingOutputBytes,
): Omit<DoneMessage, 'type'> {
let message: string
try {
const detail: unknown = error instanceof CapturedError ? error.stack ?? error.message : error
message = typeof detail === 'string' ? detail : String(detail)
} catch {
message = 'program threw an unrenderable value'
}
const rendered = typeof value === 'string' ? value : inspect(value, INSPECT_OPTIONS)
const capped = Buffer.byteLength(rendered, 'utf8') > maxValueBytes
? `${truncateUtf8Bytes(rendered, maxValueBytes)}… [truncated]`
: rendered
return { value: capped }
return prepareFailure('exception', message, remainingOutputBytes, maxOutputBytes)
}
/** One awaited binding call's settlement handles, keyed by call id in the pending map. */
@@ -183,6 +234,46 @@ export interface PendingCall {
reject(error: Error): void
}
/** Constructor shape for one program-visible binding rejection class. */
export type BindingErrorConstructor = new (memberName: string, message: string) => Error
/**
* Materialize the real error constructor declared by one namespace.
* @param descriptor - program-global class name and member-name property.
* @returns the constructor injected into the program and used for rejections.
*/
function makeBindingErrorClass(
descriptor: { name: string; memberNameProperty: string },
): BindingErrorConstructor {
return class BindingCallError extends CapturedError {
constructor(memberName: string, message: string) {
super(message)
defineBindingErrorField(this, 'name', descriptor.name)
defineBindingErrorField(this, descriptor.memberNameProperty, memberName)
}
}
}
/** Create the namespace-specific rejection for one failed binding call. */
function bindingFailure(errorClass: BindingErrorConstructor | undefined, memberName: string, message: string): Error {
return errorClass ? new errorClass(memberName, message) : new CapturedError(message)
}
/**
* Build each declared error class once so calls and `instanceof` share constructor identity.
* @param data - binding namespace declarations from the boot payload.
* @returns constructors keyed by their owning namespace global.
*/
export function makeBindingErrorClasses(
data: Pick<WorkerBootData, 'namespaces'>,
): Map<string, BindingErrorConstructor> {
const classes = new Map<string, BindingErrorConstructor>()
for (const namespace of data.namespaces) {
if (namespace.errorClass) classes.set(namespace.global, makeBindingErrorClass(namespace.errorClass))
}
return classes
}
/**
* Route host replies into the pending-call map: each reply settles its call
* at most once, and a reply for an unknown id (stray, or a duplicate answer
@@ -197,8 +288,13 @@ export function wireReplies(port: BootstrapPort, pending: Map<number, PendingCal
const entry = pending.get(message.id)
if (!entry) return
pending.delete(message.id)
if (message.ok) entry.resolve(message.value)
else entry.reject(new Error(message.message))
if (message.ok) {
const value = decodeWorkerJson(message.value)
if (value === undefined) entry.reject(new CapturedError('binding resolution must be lossless JSON'))
else entry.resolve(value)
} else {
entry.reject(new CapturedError(message.message))
}
})
}
@@ -206,12 +302,14 @@ export function wireReplies(port: BootstrapPort, pending: Map<number, PendingCal
* Build the binding namespace objects the program sees: one null-prototype global per
* namespace, each declared name an own enumerable async function that bridges over the port
* (`__proto__`/`constructor`/`toString` are ordinary keys, never prototype collisions).
* Non-cloneable arguments and host failure replies reject only the corresponding call.
* Lossy arguments reject before posting; clone failures and host failure
* replies reject only the corresponding call.
*
* @param data - the boot payload's namespace declarations (globals + names).
* @param port - the port binding calls are posted to.
* @param pending - the id-keyed map each posted call parks its handles in.
* @param nextId - the shared mutable id counter (worker-issued correlation ids).
* @param errorClasses - per-namespace constructors shared with program globals.
* @returns one namespace object per declaration, in declaration order.
*/
export function makeNamespaces(
@@ -219,22 +317,41 @@ export function makeNamespaces(
port: BootstrapPort,
pending: Map<number, PendingCall>,
nextId: { value: number },
errorClasses: Map<string, BindingErrorConstructor> = makeBindingErrorClasses(data),
): Record<string, unknown>[] {
return data.namespaces.map(({ global, names }) => {
const errorClass = errorClasses.get(global)
const namespace = Object.create(null) as Record<string, unknown>
for (const name of names) {
Object.defineProperty(namespace, name, {
enumerable: true,
value: (args: unknown): Promise<unknown> => new Promise((resolve, reject) => {
const id = nextId.value++
pending.set(id, { resolve, reject })
value: (args: unknown): Promise<unknown> => {
let detached: ReturnType<typeof snapshotCodeJsonValue>
try {
port.postMessage({ type: 'call', id, global, name, args })
} catch (error: unknown) {
pending.delete(id)
reject(new Error(`binding arguments must be structured-cloneable: ${error instanceof Error ? error.message : String(error)}`))
detached = snapshotCodeJsonValue(args)
} catch {
detached = undefined
}
}),
if (detached === undefined) {
return Promise.reject(bindingFailure(errorClass, name, 'binding arguments must be lossless JSON'))
}
return new Promise((resolve, reject) => {
const id = nextId.value++
pending.set(id, {
resolve,
reject: (error) => {
reject(bindingFailure(errorClass, name, error.message))
},
})
try {
port.postMessage({ type: 'call', id, global, name, args: encodeWorkerJson(detached) })
} catch (error: unknown) {
pending.delete(id)
const message = `binding arguments must be structured-cloneable: ${error instanceof CapturedError ? error.message : String(error)}`
reject(bindingFailure(errorClass, name, message))
}
})
},
})
}
return namespace
@@ -254,7 +371,11 @@ export async function runWorkerMain(
data: WorkerBootData,
streams: { stdout: PatchableStream; stderr: PatchableStream },
): Promise<void> {
const logs = new LogBuffer(data.maxLogBytes, (text) => { port.postMessage({ type: 'log', text }) })
const logs = new LogBuffer(
data.maxOutputBytes,
(text) => { port.postMessage({ type: 'log', text }) },
() => { port.postMessage({ type: 'output-limit' }) },
)
captureStreamWrites(logs, streams.stdout)
captureStreamWrites(logs, streams.stderr)
@@ -262,7 +383,18 @@ export async function runWorkerMain(
wireReplies(port, pending)
const nextId = { value: 1 }
const namespaces = makeNamespaces(data, port, pending, nextId)
const errorClasses = makeBindingErrorClasses(data)
const namespaces = makeNamespaces(data, port, pending, nextId, errorClasses)
const errorClassParameters: string[] = []
const errorClassValues: BindingErrorConstructor[] = []
for (const namespace of data.namespaces) {
if (!namespace.errorClass) continue
errorClassParameters.push(namespace.errorClass.name)
const errorClass = errorClasses.get(namespace.global)
/* v8 ignore next -- makeBindingErrorClasses covers every declaration in the same data. */
if (!errorClass) throw new CapturedError(`missing binding error class for ${namespace.global}`)
errorClassValues.push(errorClass)
}
const consoleShim = makeConsoleShim(logs)
let done: DoneMessage
@@ -271,12 +403,22 @@ export async function runWorkerMain(
// `AsyncFunction` is not a global. The program body is strict-mode.
/* v8 ignore next -- the arrow exists only to reach the AsyncFunction constructor; it is never invoked. */
const AsyncFunction = (async () => {}).constructor as new (...args: string[]) => (...fnArgs: unknown[]) => Promise<unknown>
const fn = new AsyncFunction(...data.namespaces.map(namespace => namespace.global), 'console', `'use strict';\n${data.code}`)
const value = await fn(...namespaces, consoleShim)
done = { type: 'done', ...prepareValue(value, data.maxValueBytes) }
const fn = new AsyncFunction(
...data.namespaces.map(namespace => namespace.global),
...errorClassParameters,
'console',
`'use strict';\n${data.code}`,
)
const value = await fn(...namespaces, ...errorClassValues, consoleShim)
done = {
type: 'done',
...prepareCompletion(value, logs.remainingOutputBytes(), data.maxOutputBytes),
}
} catch (error: unknown) {
const message = error instanceof Error ? error.stack ?? error.message : String(error)
done = { type: 'done', error: { message } }
done = {
type: 'done',
...prepareException(error, logs.remainingOutputBytes(), data.maxOutputBytes),
}
}
port.postMessage(done)
}

View File

@@ -8,14 +8,17 @@
import { Worker } from 'node:worker_threads'
import { stripTypeScriptTypes } from 'node:module'
import type { Readable } from 'node:stream'
import { fileURLToPath } from 'node:url'
import { Context } from 'cordis'
import z from 'schemastery'
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
import type { CodeBindingFunction, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import { prepareValue, truncateUtf8Bytes } from './bootstrap.ts'
import { logTruncationMarker } from './protocol.ts'
import type { CodeBindingNamespace, CodeJsonValue, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from './output-json.ts'
import { decodeWorkerJson, encodeWorkerJson } from './worker-json.ts'
import type { WorkerJsonWire } from './worker-json.ts'
/** Plugin config: every execution cap, changeable from `cordis.yml` (no hardcoded tunables). */
export interface Config {
@@ -35,14 +38,11 @@ export interface Config {
* nobody will resolve).
*/
maxWallMs?: number
/** Shared byte budget for captured log text (console + raw stream writes), truncation marked in-band. */
maxLogBytes?: number
/**
* Byte cap for the completion value, measured by its real cross-boundary
* size (string bytes, or structured-clone wire size); an oversized or
* non-cloneable value crosses as a capped string rendering.
* Hard cap for serialized log-array, completion-value, and failure-message payloads;
* fixed result-envelope syntax is excluded.
*/
maxValueBytes?: number
maxOutputBytes?: number
/** The worker's max old-generation heap in MiB (`resourceLimits`); overflow kills the worker, surfacing as kind `'worker-exit'`. */
maxOldGenerationSizeMb?: number
}
@@ -59,6 +59,9 @@ type ResolvedConfig = Required<Config>
*/
const ELU_POLL_INTERVAL_MS = 25
/** Smallest cap that can represent the counted payloads: an empty logs array plus an empty JSON failure message. */
const MIN_OUTPUT_BYTES = 4
/** ECMAScript reserved words that cannot be async-function parameter names — rejected as binding globals. */
const RESERVED_WORDS = new Set([
'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do',
@@ -71,6 +74,9 @@ const RESERVED_WORDS = new Set([
/** Valid async-function parameter name (the binding global becomes one). */
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
/** Error properties whose binding-member replacement would destroy the promised Error contract. */
const RESERVED_ERROR_PROPERTIES = new Set(['name', 'message', 'stack'])
/**
* The shell a program is wrapped in for the type-strip, matching the
* grammatical context it will execute in (an async function body, where
@@ -109,6 +115,26 @@ function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/** Resolve after a worker pipe emits all queued data, or closes/errors during termination. */
function waitForPipeDrain(stream: Readable): Promise<void> {
if (stream.readableEnded || stream.destroyed) return Promise.resolve()
return new Promise((resolve) => {
const done = (): void => {
stream.off('end', done)
stream.off('close', done)
stream.off('error', done)
resolve()
}
stream.once('end', done)
stream.once('close', done)
stream.once('error', done)
// Close the event-registration race if termination finished between the
// initial state check and the listeners above.
/* v8 ignore next -- this race cannot be scheduled deterministically between the adjacent state check and listener registration. */
if (stream.readableEnded || stream.destroyed) done()
})
}
/**
* Runtime shape gate for inbound port traffic. The peer runs MODEL CODE and
* can post anything — `null`, primitives, objects with poisoned fields — so
@@ -124,31 +150,88 @@ function parseWorkerMessage(raw: unknown): WorkerToHost | undefined {
switch (m.type) {
case 'call': {
if (typeof m.id !== 'number' || typeof m.global !== 'string' || typeof m.name !== 'string') return undefined
return { type: 'call', id: m.id, global: m.global, name: m.name, args: m.args }
return { type: 'call', id: m.id, global: m.global, name: m.name, args: m.args as WorkerJsonWire }
}
case 'log': {
if (typeof m.text !== 'string') return undefined
return { type: 'log', text: m.text }
}
case 'output-limit': return { type: 'output-limit' }
case 'done': {
if (m.error === undefined) return { type: 'done', ...m.value !== undefined ? { value: m.value } : {} }
if (m.error === undefined) return { type: 'done', ...m.value !== undefined ? { value: m.value as WorkerJsonWire } : {} }
const error = m.error
if (typeof error !== 'object' || error === null) return undefined
const message = (error as Record<string, unknown>).message
if (typeof message !== 'string') return undefined
return { type: 'done', ...m.value !== undefined ? { value: m.value } : {}, error: { message } }
const { kind, message } = error as Record<string, unknown>
if ((kind !== 'exception' && kind !== 'invalid-output' && kind !== 'output-limit') || typeof message !== 'string') return undefined
return { type: 'done', error: { kind, message } }
}
default: return undefined
}
}
/**
* Headroom the host's value re-cap grants over `maxValueBytes`: exactly the
* truncation suffix {@link prepareValue} appends, so a value the WORKER
* already capped (byte-exact prefix + this marker) passes through unchanged
* instead of being marked twice.
*/
const VALUE_RENDER_SLACK = Buffer.byteLength('… [truncated]', 'utf8')
/** One run's combined outer-output ledger; binding values never enter it. */
class OutputLedger {
private bytes = 2 // JSON serialization of the empty logs array: []
private entries = 0
constructor(private readonly maxBytes: number) {}
/** Admit one exact log entry, or report that the hard cap was crossed. */
admit(text: string, sink: string[]): boolean {
const separatorBytes = this.entries > 0 ? 1 : 0
const stringBytes = jsonStringBytesUpTo(text, this.maxBytes - this.bytes - separatorBytes)
if (stringBytes === undefined) return false
this.bytes += stringBytes + separatorBytes
this.entries += 1
sink.push(text)
return true
}
/** Finalize a successful absent-or-JSON completion against the combined cap. */
success(logs: string[], value?: CodeJsonValue): CodeRunResult {
if (value !== undefined && jsonValueBytesUpTo(value, this.maxBytes - this.bytes) === undefined) return this.limit(logs)
return { logs, ...value !== undefined ? { value } : {} }
}
/** Finalize a failure diagnostic, with output-limit taking precedence when combined bytes exceed the cap. */
failure(logs: string[], error: CodeRunFailure): CodeRunResult {
if (jsonStringBytesUpTo(error.message, this.maxBytes - this.bytes) === undefined) return this.limit(logs)
return { logs, error }
}
/** Build the explicit output-limit failure while retaining a fitting prefix of the final log. */
limit(logs: string[]): CodeRunResult {
const fullMessage = `outer output exceeded ${this.maxBytes} bytes`
// The fixed diagnostic is ASCII, so every character is one byte plus the quotes.
const messageBytes = fullMessage.length + 2
const retained: string[] = []
let retainedBytes = 2
const logBudget = this.maxBytes - messageBytes
for (const text of logs) {
const separatorBytes = retained.length > 0 ? 1 : 0
const availableBytes = logBudget - retainedBytes - separatorBytes
const stringBytes = jsonStringBytesUpTo(text, availableBytes)
if (stringBytes !== undefined) {
retained.push(text)
retainedBytes += stringBytes + separatorBytes
continue
}
const prefix = truncateJsonStringBytes(text, availableBytes)
if (prefix.length > 0) {
const prefixBytes = jsonStringBytesUpTo(prefix, availableBytes)
/* v8 ignore next -- truncateJsonStringBytes guarantees its returned prefix fits the same budget. */
if (prefixBytes === undefined) throw new Error('output ledger produced an oversized log prefix')
retained.push(prefix)
retainedBytes += prefixBytes + separatorBytes
}
break
}
const availableMessageBytes = this.maxBytes - retainedBytes
const message = truncateJsonStringBytes(fullMessage, availableMessageBytes)
return { logs: retained, error: { kind: 'output-limit', message } }
}
}
/**
* The shipped {@link CodeRuntime} backend (`ctx.codeRuntime`). Registers as
@@ -161,8 +244,7 @@ export class WorkerCodeRuntime extends CodeRuntime {
static Config: z<Config> = z.object({
computeMs: z.number().default(60_000),
maxWallMs: z.number().default(600_000),
maxLogBytes: z.number().default(65_536),
maxValueBytes: z.number().default(32_768),
maxOutputBytes: z.number().default(67_108_864),
maxOldGenerationSizeMb: z.number().default(512),
})
@@ -181,6 +263,9 @@ export class WorkerCodeRuntime extends CodeRuntime {
for (const [key, value] of Object.entries(this.config)) {
if (!(Number.isFinite(value) && value > 0)) throw new Error(`dsh-code-runtime-worker: config.${key} must be a positive number, got ${String(value)}`)
}
if (!Number.isSafeInteger(this.config.maxOutputBytes) || this.config.maxOutputBytes < MIN_OUTPUT_BYTES) {
throw new Error(`dsh-code-runtime-worker: config.maxOutputBytes must be a safe integer of at least ${MIN_OUTPUT_BYTES}, got ${String(this.config.maxOutputBytes)}`)
}
ctx.effect(() => () => this.teardown(), 'worker code-runtime teardown')
}
@@ -208,7 +293,7 @@ export class WorkerCodeRuntime extends CodeRuntime {
if (this.disposed) throw new Error('dsh-code-runtime-worker: run() after disposal')
const bindings = this.validateBindings(request)
if (request.signal?.aborted) {
return { logs: [], error: { kind: 'abort', message: String(request.signal.reason) } }
return this.failureBeforeWorker({ kind: 'abort', message: String(request.signal.reason) })
}
let code: string
@@ -219,15 +304,20 @@ export class WorkerCodeRuntime extends CodeRuntime {
// A program that does not survive the type-strip (syntax error,
// non-erasable syntax like `enum`) is a program failure, reported the
// same way a thrown exception would be — and no worker ever spawns.
return { logs: [], error: { kind: 'exception', message: messageOf(error) } }
return this.failureBeforeWorker({ kind: 'exception', message: messageOf(error) })
}
return await this.execute(request, code, bindings)
}
/** Reject (seam misuse) malformed binding namespaces: non-identifier or reserved globals, duplicates, and the `console` collision. */
private validateBindings(request: CodeRunRequest): Map<string, Record<string, CodeBindingFunction>> {
const bindings = new Map<string, Record<string, CodeBindingFunction>>()
/** Apply the outer-output ledger to failures that occur before a worker owns one. */
private failureBeforeWorker(error: CodeRunFailure): CodeRunResult {
return new OutputLedger(this.config.maxOutputBytes).failure([], error)
}
/** Reject malformed binding globals or typed-error declarations as seam misuse. */
private validateBindings(request: CodeRunRequest): Map<string, CodeBindingNamespace> {
const bindings = new Map<string, CodeBindingNamespace>()
for (const namespace of request.bindings) {
if (!IDENTIFIER.test(namespace.global) || RESERVED_WORDS.has(namespace.global)) {
throw new Error(`dsh-code-runtime-worker: binding global ${JSON.stringify(namespace.global)} is not a usable identifier`)
@@ -235,7 +325,23 @@ export class WorkerCodeRuntime extends CodeRuntime {
if (namespace.global === 'console' || bindings.has(namespace.global)) {
throw new Error(`dsh-code-runtime-worker: duplicate binding global ${JSON.stringify(namespace.global)}`)
}
bindings.set(namespace.global, namespace.functions)
bindings.set(namespace.global, namespace)
}
const errorClassNames = new Set<string>()
for (const namespace of request.bindings) {
const descriptor = namespace.errorClass
if (!descriptor) continue
if (!IDENTIFIER.test(descriptor.name) || RESERVED_WORDS.has(descriptor.name)) {
throw new Error(`dsh-code-runtime-worker: binding error class ${JSON.stringify(descriptor.name)} is not a usable identifier`)
}
if (descriptor.name === 'console' || bindings.has(descriptor.name) || errorClassNames.has(descriptor.name)) {
throw new Error(`dsh-code-runtime-worker: duplicate injected global ${JSON.stringify(descriptor.name)}`)
}
if (descriptor.memberNameProperty.length === 0 || RESERVED_ERROR_PROPERTIES.has(descriptor.memberNameProperty)) {
throw new Error(`dsh-code-runtime-worker: binding error member property ${JSON.stringify(descriptor.memberNameProperty)} is not usable`)
}
errorClassNames.add(descriptor.name)
}
return bindings
}
@@ -244,13 +350,16 @@ export class WorkerCodeRuntime extends CodeRuntime {
private execute(
request: CodeRunRequest,
code: string,
bindings: Map<string, Record<string, CodeBindingFunction>>,
bindings: Map<string, CodeBindingNamespace>,
): Promise<CodeRunResult> {
const bootData: WorkerBootData = {
code,
namespaces: [...bindings].map(([global, functions]) => ({ global, names: Object.keys(functions) })),
maxLogBytes: this.config.maxLogBytes,
maxValueBytes: this.config.maxValueBytes,
namespaces: [...bindings].map(([global, namespace]) => ({
global,
names: Object.keys(namespace.functions),
...namespace.errorClass ? { errorClass: namespace.errorClass } : {},
})),
maxOutputBytes: this.config.maxOutputBytes,
}
const worker = new Worker(WORKER_PATH, {
workerData: bootData,
@@ -274,28 +383,22 @@ export class WorkerCodeRuntime extends CodeRuntime {
const answered = new Set<number>()
const logs: string[] = []
const strayLogs: string[] = []
const output = new OutputLedger(this.config.maxOutputBytes)
let terminalOverride: CodeRunResult | undefined
// One host-side budget covers normal, forged, and stray-pipe log entries. The first
// overflow emits the shared in-band marker and drops everything after it.
let logBudget = this.config.maxLogBytes
let logsTruncated = false
const admit = (text: string, sink: string[]): void => {
if (logsTruncated) return
const cost = Buffer.byteLength(text, 'utf8')
if (cost > logBudget) {
logsTruncated = true
sink.push(logTruncationMarker(this.config.maxLogBytes))
return
}
logBudget -= cost
sink.push(text)
}
// No settled guard: `finish` snapshots the arrays when it resolves, so
// a chunk flushing after settlement mutates only the discarded buffers,
// and the ledger bounds that growth until the pipes close.
// Pipe and message-port delivery are independent. Continue bounded pipe
// capture after a terminal message while worker termination drains bytes
// that were already queued; `finish` materializes the result only after
// termination completes.
const captureStray = (chunk: Buffer): void => {
admit(chunk.toString('utf8'), strayLogs)
/* v8 ignore next -- a second post-overflow chunk races immediate worker termination; the first overflow path is covered. */
if (terminalOverride !== undefined) return
const text = chunk.toString('utf8')
if (!output.admit(text, strayLogs)) {
const limited = output.limit([...logs, ...strayLogs, text])
terminalOverride = limited
finish(limited)
}
}
worker.stdout.on('data', captureStray)
worker.stderr.on('data', captureStray)
@@ -304,27 +407,42 @@ export class WorkerCodeRuntime extends CodeRuntime {
// logs captured before timeout, abort, or failure remain in the result.
let finishResolve!: () => void
const finished = new Promise<void>((done) => { finishResolve = done })
const finish = (result: Omit<CodeRunResult, 'logs'>): void => {
const finish = (finalize: CodeRunResult | (() => CodeRunResult)): void => {
if (settled) return
settled = true
clearInterval(eluTimer)
clearTimeout(wallTimer)
request.signal?.removeEventListener('abort', onAbort)
this.live.delete(live)
void worker.terminate().then(() => {
// Let the poll phase deliver pipe bytes already queued independently
// of the terminal port message before termination closes the streams.
void new Promise<void>((resume) => { setImmediate(resume) }).then(async () => {
const stdoutDrained = waitForPipeDrain(worker.stdout)
const stderrDrained = waitForPipeDrain(worker.stderr)
await Promise.all([worker.terminate(), stdoutDrained, stderrDrained])
const result = terminalOverride ?? (typeof finalize === 'function' ? finalize() : finalize)
finishResolve()
resolve({ ...result, logs: [...logs, ...strayLogs] })
resolve(result)
})
}
const onDone = (message: WorkerToHost): void => {
if (message.type !== 'done') return
// Re-cap forged completion traffic at the hostile boundary. Honest worker-capped values
// pass unchanged via VALUE_RENDER_SLACK; error text is bounded too.
finish({
...prepareValue(message.value, this.config.maxValueBytes + VALUE_RENDER_SLACK),
...message.error ? { error: { kind: 'exception' as const, message: truncateUtf8Bytes(message.error.message, this.config.maxValueBytes) } } : {},
})
if (message.error) {
const error = message.error
finish(() => output.failure([...logs, ...strayLogs], error))
return
}
if (message.value === undefined) {
finish(() => output.success([...logs, ...strayLogs]))
return
}
const value = decodeWorkerJson(message.value)
if (value === undefined) {
finish(() => output.failure([...logs, ...strayLogs], { kind: 'invalid-output', message: 'program completion must be lossless JSON' }))
} else {
finish(() => output.success([...logs, ...strayLogs], value))
}
}
const onCall = (message: WorkerToHost): void => {
@@ -336,15 +454,11 @@ export class WorkerCodeRuntime extends CodeRuntime {
answered.add(message.id)
const reply = (payload: ReplyMessage): void => {
if (settled) return
try {
worker.postMessage(payload)
} catch {
// The reply value failed structured clone; renegotiate as an error
// reply, which is always clone-plain. Nothing else throws here.
worker.postMessage({ type: 'reply', id: message.id, ok: false, message: 'binding resolution is not structured-cloneable' })
}
// Canonical resolutions were snapshotted as lossless JSON before
// this point, so this payload is structured-cloneable by contract.
worker.postMessage(payload)
}
const record = bindings.get(message.global)
const record = bindings.get(message.global)?.functions
// Own-property lookup only: a forged name like 'constructor' or
// 'hasOwnProperty' must not walk the record's prototype chain and
// reach a callable the consumer never declared.
@@ -353,9 +467,25 @@ export class WorkerCodeRuntime extends CodeRuntime {
reply({ type: 'reply', id: message.id, ok: false, message: `unknown binding ${JSON.stringify(`${message.global}.${message.name}`)}` })
return
}
const args = decodeWorkerJson(message.args)
if (args === undefined) {
reply({ type: 'reply', id: message.id, ok: false, message: 'binding arguments must be lossless JSON' })
return
}
void (async () => {
try {
reply({ type: 'reply', id: message.id, ok: true, value: await fn(message.args) })
const resolved = await fn(args)
let value: CodeJsonValue | undefined
try {
value = snapshotJsonValue(resolved)
} catch {
value = undefined
}
if (value === undefined) {
reply({ type: 'reply', id: message.id, ok: false, message: 'binding resolution must be lossless JSON' })
} else {
reply({ type: 'reply', id: message.id, ok: true, value: encodeWorkerJson(value) })
}
} catch (error: unknown) {
reply({ type: 'reply', id: message.id, ok: false, message: messageOf(error) })
}
@@ -367,15 +497,24 @@ export class WorkerCodeRuntime extends CodeRuntime {
// this listener would crash the host process. Junk drops silently.
const message = parseWorkerMessage(raw)
if (!message) return
if (message.type === 'log' && !settled) admit(message.text, logs)
if (message.type === 'log' && !settled && !output.admit(message.text, logs)) {
const limited = output.limit([...logs, ...strayLogs, message.text])
finish(limited)
return
}
if (message.type === 'output-limit' && !settled) {
const limited = output.limit([...logs, ...strayLogs])
finish(limited)
return
}
onCall(message)
onDone(message)
})
worker.on('error', (error: Error) => {
finish({ error: { kind: 'worker-exit', message: `worker error: ${error.message}` } })
finish(() => output.failure([...logs, ...strayLogs], { kind: 'worker-exit', message: `worker error: ${error.message}` }))
})
worker.on('exit', (exitCode: number) => {
finish({ error: { kind: 'worker-exit', message: `worker exited with code ${exitCode} before completing` } })
finish(() => output.failure([...logs, ...strayLogs], { kind: 'worker-exit', message: `worker exited with code ${exitCode} before completing` }))
})
// The compute budget reads the worker's own measured busy time, so a
@@ -384,21 +523,21 @@ export class WorkerCodeRuntime extends CodeRuntime {
const eluTimer = setInterval(() => {
const elu = worker.performance.eventLoopUtilization()
if (elu.active > this.config.computeMs) {
finish({ error: { kind: 'timeout', message: `compute budget exhausted (${this.config.computeMs}ms busy)` } })
finish(() => output.failure([...logs, ...strayLogs], { kind: 'timeout', message: `compute budget exhausted (${this.config.computeMs}ms busy)` }))
}
}, ELU_POLL_INTERVAL_MS)
const wallTimer = setTimeout(() => {
finish({ error: { kind: 'timeout', message: `wall-clock ceiling reached (${this.config.maxWallMs}ms)` } })
finish(() => output.failure([...logs, ...strayLogs], { kind: 'timeout', message: `wall-clock ceiling reached (${this.config.maxWallMs}ms)` }))
}, this.config.maxWallMs)
const onAbort = (): void => {
finish({ error: { kind: 'abort', message: String(request.signal?.reason) } })
finish(() => output.failure([...logs, ...strayLogs], { kind: 'abort', message: String(request.signal?.reason) }))
}
request.signal?.addEventListener('abort', onAbort, { once: true })
const live: LiveRun = {
worker,
finished,
settle: (failure: CodeRunFailure) => { finish({ error: failure }) },
settle: (failure: CodeRunFailure) => { finish(() => output.failure([...logs, ...strayLogs], failure)) },
}
this.live.add(live)
})

View File

@@ -0,0 +1,179 @@
/** JSON string-prefix accounting for the outer-output ledger. @module @deepseek-ai/dsh-code-runtime-worker/output-json */
import type { CodeJsonValue } from '@deepseek-ai/dsh-code-runtime'
type IntrinsicCallable = (this: unknown, ...args: unknown[]) => unknown
const intrinsicReflectApply = Reflect.apply as (
target: IntrinsicCallable,
thisArgument: unknown,
argumentsList: readonly unknown[],
) => unknown
const intrinsicArrayIsArray = Array.isArray
const IntrinsicBuffer = Buffer
const intrinsicBufferByteLength = Reflect.get(Buffer, 'byteLength') as IntrinsicCallable
const intrinsicObjectCreate = Object.create
const intrinsicObjectDefineProperty = Object.defineProperty
const intrinsicObjectKeys = Object.keys
const intrinsicString = String
const intrinsicStringCharCodeAt = Reflect.get(String.prototype, 'charCodeAt') as IntrinsicCallable
const intrinsicStringCodePointAt = Reflect.get(String.prototype, 'codePointAt') as IntrinsicCallable
const intrinsicStringSlice = Reflect.get(String.prototype, 'slice') as IntrinsicCallable
/** Build a data descriptor that cannot inherit model-defined accessor fields. */
function dataDescriptor(value: unknown): PropertyDescriptor {
const descriptor = intrinsicObjectCreate(null) as PropertyDescriptor
descriptor.value = value
return descriptor
}
/** Define an ordinary enumerable data slot without a prototype-bearing descriptor. */
function defineEnumerableDataProperty(target: object, key: PropertyKey, value: unknown): void {
const descriptor = dataDescriptor(value)
descriptor.enumerable = true
descriptor.configurable = true
descriptor.writable = true
intrinsicObjectDefineProperty(target, key, descriptor)
}
/** UTF-8 byte length through the module-captured Node intrinsic. */
function byteLength(text: string): number {
return intrinsicReflectApply(intrinsicBufferByteLength, IntrinsicBuffer, [text, 'utf8']) as number
}
/** Append without consulting a model-mutated `Array.prototype`. */
function append<T>(target: T[], value: T): void {
defineEnumerableDataProperty(target, target.length, value)
}
/** Pop without consulting a model-mutated `Array.prototype`. */
function takeLast<T>(target: T[]): T | undefined {
if (target.length === 0) return undefined
const index = target.length - 1
const value = target[index]
intrinsicObjectDefineProperty(target, 'length', dataDescriptor(index))
return value
}
/** One code-point-aligned character from a string. */
function characterAt(text: string, index: number): string {
const codePoint = intrinsicReflectApply(intrinsicStringCodePointAt, text, [index]) as number
const width = codePoint > 0xffff ? 2 : 1
return intrinsicReflectApply(intrinsicStringSlice, text, [index, index + width]) as string
}
/** Serialized bytes contributed by one complete Unicode code point inside JSON quotes. */
function serializedCharacterBytes(character: string): number {
if (character.length === 2) return 4
if (character === '"' || character === '\\') return 2
const code = intrinsicReflectApply(intrinsicStringCharCodeAt, character, [0]) as number
if (code >= 0xd800 && code <= 0xdfff) return 6
if (code < 0x20) return code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d ? 2 : 6
return byteLength(character)
}
/**
* Measure one JSON string without materializing its complete escaped form.
* @param text - the candidate string.
* @param maxBytes - largest serialized size the caller can admit.
* @returns Exact serialized bytes, or `undefined` as soon as the cap is crossed.
*/
export function jsonStringBytesUpTo(text: string, maxBytes: number): number | undefined {
if (maxBytes < 2) return undefined
let bytes = 2
for (let index = 0; index < text.length;) {
const character = characterAt(text, index)
bytes += serializedCharacterBytes(character)
if (bytes > maxBytes) return undefined
index += character.length
}
return bytes
}
/**
* Measure one lossless JSON value without allocating its serialized form.
* @param value - already validated lossless JSON.
* @param maxBytes - largest serialized size the caller can admit.
* @returns Exact serialized bytes, or `undefined` as soon as the cap is crossed.
*/
export function jsonValueBytesUpTo(value: CodeJsonValue, maxBytes: number): number | undefined {
type Task =
| { kind: 'value'; value: CodeJsonValue }
| { kind: 'array'; value: CodeJsonValue[]; index: number }
| { kind: 'object'; value: Record<string, CodeJsonValue>; keys: string[]; index: number }
let bytes = 0
const add = (cost: number): boolean => {
bytes += cost
return bytes <= maxBytes
}
const tasks: Task[] = [{ kind: 'value', value }]
for (let task = takeLast(tasks); task !== undefined; task = takeLast(tasks)) {
if (task.kind === 'value') {
const current = task.value
if (current === null) {
if (!add(4)) return undefined
} else if (typeof current === 'string') {
const stringBytes = jsonStringBytesUpTo(current, maxBytes - bytes)
if (stringBytes === undefined) return undefined
bytes += stringBytes
} else if (typeof current === 'number') {
if (!add(byteLength(intrinsicString(current)))) return undefined
} else if (typeof current === 'boolean') {
if (!add(current ? 4 : 5)) return undefined
} else if (intrinsicArrayIsArray(current)) {
if (!add(2)) return undefined
if (current.length > 0) append(tasks, { kind: 'array', value: current, index: 0 })
} else {
if (!add(2)) return undefined
const keys = intrinsicObjectKeys(current)
if (keys.length > 0) append(tasks, { kind: 'object', value: current, keys, index: 0 })
}
continue
}
if (task.index > 0 && !add(1)) return undefined
if (task.kind === 'array') {
const item = task.value[task.index]
if (item === undefined) return undefined
if (task.index + 1 < task.value.length) append(tasks, { ...task, index: task.index + 1 })
append(tasks, { kind: 'value', value: item })
continue
}
const key = task.keys[task.index]
/* v8 ignore next -- an object frame is created and advanced only for an existing Object.keys entry. */
if (key === undefined) return undefined
const keyBytes = jsonStringBytesUpTo(key, maxBytes - bytes)
if (keyBytes === undefined) return undefined
if (!add(keyBytes + 1)) return undefined
const item = task.value[key]
if (item === undefined) return undefined
if (task.index + 1 < task.keys.length) append(tasks, { ...task, index: task.index + 1 })
append(tasks, { kind: 'value', value: item })
}
return bytes
}
/**
* Return the longest code-point-aligned prefix whose JSON string encoding,
* including its surrounding quotes, fits `maxBytes`.
*
* @param text - the candidate string.
* @param maxBytes - serialized JSON-string bytes available.
* @returns the fitting prefix, or an empty string when even useful content cannot fit.
*/
export function truncateJsonStringBytes(text: string, maxBytes: number): string {
if (maxBytes < 2) return ''
let bytes = 2
let end = 0
for (let index = 0; index < text.length;) {
const character = characterAt(text, index)
const cost = serializedCharacterBytes(character)
if (bytes + cost > maxBytes) break
bytes += cost
end += character.length
index += character.length
}
return end === text.length ? text : intrinsicReflectApply(intrinsicStringSlice, text, [0, end]) as string
}

View File

@@ -5,16 +5,20 @@
* @module @deepseek-ai/dsh-code-runtime-worker/src/protocol
*/
import type { WorkerJsonWire } from './worker-json.ts'
/** What the host hands the worker at spawn, via `workerData`. */
export interface WorkerBootData {
/** The type-stripped (plain JS) program body. */
code: string
/** Binding namespaces to materialize: the global name plus the function names (functions themselves stay host-side). */
namespaces: { global: string; names: string[] }[]
/** Shared byte budget for captured log text; exceeding it drops further entries after one in-band marker. */
maxLogBytes: number
/** Byte cap for the rendered completion value (see the value-preparation contract in bootstrap.ts). */
maxValueBytes: number
/** Binding namespaces to materialize; functions themselves stay host-side. */
namespaces: {
global: string
names: string[]
errorClass?: { name: string; memberNameProperty: string }
}[]
/** Hard cap for the combined serialized outer logs plus completion value or failure diagnostic. */
maxOutputBytes: number
}
/** Worker → host: one bridged binding call. */
@@ -26,8 +30,8 @@ interface CallMessage {
global: string
/** The function name within the namespace. */
name: string
/** The single argument, structured-clone-plain. */
args: unknown
/** The single argument as a flat lossless-JSON wire value. */
args: WorkerJsonWire
}
/** Worker → host: captured text, streamed eagerly so output survives a mid-run termination (timeout, abort, OOM). */
@@ -36,37 +40,29 @@ interface LogMessage {
text: string
}
/** Worker → host: worker-side capture or completion measurement exceeded the outer cap. */
interface OutputLimitMessage {
type: 'output-limit'
}
/**
* Worker → host: the program settled. `error` carries a program exception
* (the only failure the bootstrap itself can report — budgets, aborts, and
* substrate death are observed host-side). `value` is present only on a
* clean completion that produced one (already size-capped and
* clone-safe per the bootstrap's value preparation). Logs are NOT carried
* here — they streamed eagerly as {@link LogMessage}s.
* Worker → host: the program settled. `error` carries a program exception,
* invalid completion, or output overflow (budgets, aborts, and substrate death
* are observed host-side). `value` is present only on a clean completion that
* produced one, as a flat wire value already lossless and admitted against
* the remaining combined output cap. Logs are NOT carried here — they streamed
* eagerly as {@link LogMessage}s.
*/
export interface DoneMessage {
type: 'done'
value?: unknown
error?: { message: string }
value?: WorkerJsonWire
error?: { kind: 'exception' | 'invalid-output' | 'output-limit'; message: string }
}
/** Every message the worker sends. */
export type WorkerToHost = CallMessage | LogMessage | DoneMessage
export type WorkerToHost = CallMessage | LogMessage | OutputLimitMessage | DoneMessage
/** Host → worker: the answer to one {@link CallMessage}. */
export type ReplyMessage =
| { type: 'reply'; id: number; ok: true; value: unknown }
| { type: 'reply'; id: number; ok: true; value: WorkerJsonWire }
| { type: 'reply'; id: number; ok: false; message: string }
/**
* The in-band marker entry text announcing that log capture stopped at the
* byte budget. Shared wire vocabulary: the worker's LogBuffer emits it when
* ITS budget exhausts, and the host emits the identical text when its own
* ledger drops an entry first (forged port traffic, stray pipe bytes) — so
* a truncated run reads the same however the cap was hit.
* @param maxBytes - the configured `maxLogBytes` the marker names.
* @returns the marker line.
*/
export function logTruncationMarker(maxBytes: number): string {
return `[dsh-code-runtime-worker] log capture truncated at ${maxBytes} bytes`
}

View File

@@ -0,0 +1,417 @@
/** Lossless-JSON snapshots for the dependency-free source worker closure. @module @deepseek-ai/dsh-code-runtime-worker/worker-json */
import type { CodeJsonValue } from '@deepseek-ai/dsh-code-runtime'
/* jscpd:ignore-start -- the source worker mirrors session JSON helpers without workspace runtime imports */
type IntrinsicCallable = (this: unknown, ...args: unknown[]) => unknown
const intrinsicFunctionToString = Reflect.get(Function.prototype, 'toString') as IntrinsicCallable
const intrinsicReflectApply = Reflect.get(Reflect, 'apply') as (
target: IntrinsicCallable,
thisArgument: unknown,
argumentsList: readonly unknown[],
) => unknown
const IntrinsicError = Error
const IntrinsicSet = Set
const intrinsicArrayIsArray = Array.isArray
const intrinsicArrayPrototype = Array.prototype
const intrinsicNumberIsFinite = Number.isFinite
const intrinsicNumberIsSafeInteger = Number.isSafeInteger
const intrinsicObjectCreate = Object.create
const intrinsicObjectDefineProperty = Object.defineProperty
const intrinsicObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor
const intrinsicObjectGetPrototypeOf = Object.getPrototypeOf
const intrinsicObjectHasOwn = Object.hasOwn
const intrinsicObjectIs = Object.is
const intrinsicObjectKeys = Object.keys
const intrinsicObjectPrototype = Object.prototype
const intrinsicObjectPropertyIsEnumerable = Reflect.get(intrinsicObjectPrototype, 'propertyIsEnumerable') as IntrinsicCallable
const intrinsicReflectOwnKeys = Reflect.ownKeys
const intrinsicSetAdd = Reflect.get(Set.prototype, 'add') as IntrinsicCallable
const intrinsicSetDelete = Reflect.get(Set.prototype, 'delete') as IntrinsicCallable
const intrinsicSetHas = Reflect.get(Set.prototype, 'has') as IntrinsicCallable
/** Build a data descriptor that cannot inherit model-defined accessor fields. */
function dataDescriptor(value: unknown): PropertyDescriptor {
const descriptor = intrinsicObjectCreate(null) as PropertyDescriptor
descriptor.value = value
return descriptor
}
/** Define an ordinary enumerable data slot without a prototype-bearing descriptor. */
function defineEnumerableDataProperty(target: object, key: PropertyKey, value: unknown): void {
const descriptor = dataDescriptor(value)
descriptor.enumerable = true
descriptor.configurable = true
descriptor.writable = true
intrinsicObjectDefineProperty(target, key, descriptor)
}
/** Append without consulting a model-mutated `Array.prototype`. */
function append<T>(target: T[], value: T): void {
defineEnumerableDataProperty(target, target.length, value)
}
/** Pop without consulting a model-mutated `Array.prototype`. */
function takeLast<T>(target: T[]): T | undefined {
if (target.length === 0) return undefined
const index = target.length - 1
const value = target[index]
intrinsicObjectDefineProperty(target, 'length', dataDescriptor(index))
return value
}
/** Whether one captured-intrinsic Set contains a value. */
function setHas<T>(target: Set<T>, value: T): boolean {
return intrinsicReflectApply(intrinsicSetHas, target, [value]) as boolean
}
/** Add to one captured-intrinsic Set. */
function setAdd<T>(target: Set<T>, value: T): void {
intrinsicReflectApply(intrinsicSetAdd, target, [value])
}
/** Delete from one captured-intrinsic Set. */
function setDelete<T>(target: Set<T>, value: T): void {
intrinsicReflectApply(intrinsicSetDelete, target, [value])
}
/** Whether a realm-owned intrinsic prototype is backed by its native constructor. */
function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): boolean {
const descriptor = intrinsicObjectGetOwnPropertyDescriptor(prototype, 'constructor')
const constructor: unknown = descriptor?.value
if (typeof constructor !== 'function') return false
try {
return constructor.name === name
&& constructor.prototype === prototype
&& intrinsicReflectApply(intrinsicFunctionToString, constructor, []) === `function ${name}() { [native code] }`
} catch {
return false
}
}
/** Whether a candidate is a foreign realm's intrinsic `Object.prototype`. */
function isForeignIntrinsicObjectPrototype(value: object): boolean {
return intrinsicObjectGetPrototypeOf(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 = intrinsicObjectGetPrototypeOf(value)
if (prototype === intrinsicArrayPrototype) return true
if (!intrinsicArrayIsArray(prototype) || !hasIntrinsicConstructor(prototype, 'Array')) return false
const objectPrototype: unknown = intrinsicObjectGetPrototypeOf(prototype)
return typeof objectPrototype === 'object'
&& objectPrototype !== null
&& isForeignIntrinsicObjectPrototype(objectPrototype)
}
/** Whether an object is a plain or null-prototype record from any JavaScript realm. */
function hasPlainObjectPrototype(value: object): boolean {
const prototype: unknown = intrinsicObjectGetPrototypeOf(value)
return prototype === null
|| prototype === intrinsicObjectPrototype
|| typeof prototype === 'object' && isForeignIntrinsicObjectPrototype(prototype)
}
/** Return every JSON-visible object key, or reject own data JSON would discard. */
function enumerableStringKeys(value: object): string[] | undefined {
const keys = intrinsicReflectOwnKeys(value)
for (let index = 0; index < keys.length; index++) {
const key = keys[index]
if (typeof key !== 'string' || !intrinsicReflectApply(intrinsicObjectPropertyIsEnumerable, value, [key])) return undefined
}
return keys as string[]
}
type SnapshotDestination =
| { kind: 'root' }
| { kind: 'array'; target: CodeJsonValue[]; index: number }
| { kind: 'object'; target: Record<string, CodeJsonValue>; key: string }
type SnapshotTask =
| { kind: 'visit'; value: unknown; destination: SnapshotDestination }
| { kind: 'array-item'; source: unknown[]; index: number; target: CodeJsonValue[] }
| { kind: 'object-property'; source: Record<string, unknown>; key: string; target: Record<string, CodeJsonValue> }
| { kind: 'leave'; source: object }
/**
* Validate and detach one worker-boundary value without loading another
* workspace package at runtime. This mirrors the session-owned canonical
* JSON boundary while remaining safe to import from the unbuilt worker.
* Its iterative traversal adds no JavaScript call-stack depth limit.
*
* @param value - the candidate completion value.
* @returns a detached lossless-JSON snapshot, or `undefined` when invalid.
*/
export function snapshotCodeJsonValue(value: unknown): CodeJsonValue | undefined {
const active = new IntrinsicSet<object>()
let root: CodeJsonValue | undefined
const assign = (destination: SnapshotDestination, item: CodeJsonValue): void => {
if (destination.kind === 'root') {
root = item
} else if (destination.kind === 'array') {
defineEnumerableDataProperty(destination.target, destination.index, item)
} else {
defineEnumerableDataProperty(destination.target, destination.key, item)
}
}
const tasks: SnapshotTask[] = [{ kind: 'visit', value, destination: { kind: 'root' } }]
for (let task = takeLast(tasks); task !== undefined; task = takeLast(tasks)) {
if (task.kind === 'leave') {
setDelete(active, task.source)
continue
}
if (task.kind === 'array-item') {
if (!intrinsicObjectHasOwn(task.source, task.index)) return undefined
append(tasks, {
kind: 'visit',
value: task.source[task.index],
destination: { kind: 'array', target: task.target, index: task.index },
})
continue
}
if (task.kind === 'object-property') {
append(tasks, {
kind: 'visit',
value: task.source[task.key],
destination: { kind: 'object', target: task.target, key: task.key },
})
continue
}
const candidate = task.value
if (candidate === null) {
assign(task.destination, null)
continue
}
if (typeof candidate === 'boolean' || typeof candidate === 'string') {
assign(task.destination, candidate)
continue
}
if (typeof candidate === 'number') {
if (!intrinsicNumberIsFinite(candidate) || intrinsicObjectIs(candidate, -0)) return undefined
assign(task.destination, candidate)
continue
}
if (typeof candidate !== 'object') return undefined
if (setHas(active, candidate)) return undefined
if (intrinsicArrayIsArray(candidate)) {
if (!hasPlainArrayPrototype(candidate)) return undefined
const length = candidate.length
if (intrinsicReflectOwnKeys(candidate).length !== length + 1) return undefined
const target: CodeJsonValue[] = []
assign(task.destination, target)
setAdd(active, candidate)
append(tasks, { kind: 'leave', source: candidate })
for (let index = length - 1; index >= 0; index--) {
append(tasks, { kind: 'array-item', source: candidate, index, target })
}
continue
}
if (!hasPlainObjectPrototype(candidate)) return undefined
const keys = enumerableStringKeys(candidate)
if (keys === undefined) return undefined
const target: Record<string, CodeJsonValue> = {}
assign(task.destination, target)
setAdd(active, candidate)
append(tasks, { kind: 'leave', source: candidate })
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
append(tasks, { kind: 'object-property', source: candidate as Record<string, unknown>, key, target })
}
}
return root
}
interface ArrayWireToken {
kind: 'array'
length: number
}
interface ObjectWireToken {
kind: 'object'
keys: string[]
}
type WorkerJsonToken = null | boolean | number | string | ArrayWireToken | ObjectWireToken
/**
* A pre-order, bounded-depth transport for one lossless JSON value. Container
* markers and scalar leaves share one flat token array, so `worker_threads`
* never has to structured-clone the value's application nesting.
*/
export type WorkerJsonWire = WorkerJsonToken[]
/**
* Flatten one validated JSON value for the worker-thread message port.
* @param value - the lossless JSON value to transport.
* @returns a pre-order token stream whose own nesting is bounded.
*/
export function encodeWorkerJson(value: CodeJsonValue): WorkerJsonWire {
const wire: WorkerJsonWire = []
const pending: CodeJsonValue[] = [value]
for (let current = takeLast(pending); current !== undefined; current = takeLast(pending)) {
if (current === null || typeof current === 'boolean' || typeof current === 'number' || typeof current === 'string') {
append(wire, current)
continue
}
if (intrinsicArrayIsArray(current)) {
append(wire, { kind: 'array', length: current.length })
for (let index = current.length - 1; index >= 0; index--) {
const item = current[index]
if (item === undefined) throw new IntrinsicError('cannot encode a sparse JSON array')
append(pending, item)
}
continue
}
const keys = intrinsicObjectKeys(current)
append(wire, { kind: 'object', keys })
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 IntrinsicError('cannot encode a missing JSON object key')
const item = current[key]
if (item === undefined) throw new IntrinsicError('cannot encode an undefined JSON object property')
append(pending, item)
}
}
return wire
}
type DecodeFrame =
| { kind: 'array'; target: CodeJsonValue[]; length: number; index: number }
| { kind: 'object'; target: Record<string, CodeJsonValue>; keys: string[]; index: number }
/** Whether an array contains exactly its dense indexed slots and `length`. */
function isDenseArray(value: unknown[]): boolean {
if (!hasPlainArrayPrototype(value) || intrinsicReflectOwnKeys(value).length !== value.length + 1) return false
for (let index = 0; index < value.length; index++) {
if (!intrinsicObjectHasOwn(value, index)) return false
}
return true
}
/** Whether one exact string-key list contains a key, without consulting its prototype. */
function keysContain(keys: string[], expected: string): boolean {
for (let index = 0; index < keys.length; index++) {
if (keys[index] === expected) return true
}
return false
}
/** Return one exact container marker, or reject any extra/missing fields. */
function containerToken(value: object): ArrayWireToken | ObjectWireToken | undefined {
if (intrinsicArrayIsArray(value) || !hasPlainObjectPrototype(value)) return undefined
const keys = enumerableStringKeys(value)
if (keys === undefined) return undefined
const token = value as Record<string, unknown>
if (token.kind === 'array') {
if (keys.length !== 2 || !keysContain(keys, 'kind') || !keysContain(keys, 'length')) return undefined
const length = token.length
return typeof length === 'number' && intrinsicNumberIsSafeInteger(length) && length >= 0
? { kind: 'array', length }
: undefined
}
if (token.kind === 'object') {
if (keys.length !== 2 || !keysContain(keys, 'kind') || !keysContain(keys, 'keys')) return undefined
const objectKeys = token.keys
if (!intrinsicArrayIsArray(objectKeys) || !isDenseArray(objectKeys)) return undefined
const unique = new IntrinsicSet<string>()
const normalizedKeys: string[] = []
const objectKeyValues = objectKeys as unknown[]
for (let index = 0; index < objectKeyValues.length; index++) {
const key = objectKeyValues[index]
if (typeof key !== 'string' || setHas(unique, key)) return undefined
setAdd(unique, key)
append(normalizedKeys, key)
}
return { kind: 'object', keys: normalizedKeys }
}
return undefined
}
/**
* Rebuild one lossless JSON value from the flat worker-thread wire format.
* Malformed or incomplete traffic returns `undefined`; traversal is iterative
* and therefore independent of the transported value's application depth.
* @param input - untrusted message-port payload.
* @returns the detached JSON value, or `undefined` when the wire is invalid.
*/
export function decodeWorkerJson(input: unknown): CodeJsonValue | undefined {
try {
if (!intrinsicArrayIsArray(input) || !isDenseArray(input) || input.length === 0) return undefined
const wire = input as unknown[]
const frames: DecodeFrame[] = []
let root: CodeJsonValue | undefined
let rootAssigned = false
const attach = (value: CodeJsonValue): boolean => {
const parent = frames[frames.length - 1]
if (!parent) {
if (rootAssigned) return false
root = value
rootAssigned = true
return true
}
/* v8 ignore next -- completed frames are popped before another token can attach. */
if (parent.index >= (parent.kind === 'array' ? parent.length : parent.keys.length)) return false
if (parent.kind === 'array') {
append(parent.target, value)
} else {
const key = parent.keys[parent.index]
/* v8 ignore next -- object frames are built from validated keys and their exact length. */
if (key === undefined) return false
defineEnumerableDataProperty(parent.target, key, value)
}
parent.index += 1
return true
}
for (let tokenIndex = 0; tokenIndex < wire.length; tokenIndex++) {
const token = wire[tokenIndex]
let value: CodeJsonValue
let frame: DecodeFrame | undefined
if (token === null || typeof token === 'boolean' || typeof token === 'string') {
value = token
} else if (typeof token === 'number') {
if (!intrinsicNumberIsFinite(token) || intrinsicObjectIs(token, -0)) return undefined
value = token
} else {
if (typeof token !== 'object') return undefined
const marker = containerToken(token)
if (!marker) return undefined
const remainingTokens = wire.length - tokenIndex - 1
if (marker.kind === 'array') {
if (marker.length > remainingTokens) return undefined
const target: CodeJsonValue[] = []
value = target
if (marker.length > 0) frame = { kind: 'array', target, length: marker.length, index: 0 }
} else {
if (marker.keys.length > remainingTokens) return undefined
const target: Record<string, CodeJsonValue> = {}
value = target
if (marker.keys.length > 0) frame = { kind: 'object', target, keys: marker.keys, index: 0 }
}
}
if (!attach(value)) return undefined
if (frame) append(frames, frame)
while (frames.length > 0) {
const current = frames[frames.length - 1]
/* v8 ignore next -- the loop condition guarantees a final frame. */
if (current === undefined) break
if (current.index < (current.kind === 'array' ? current.length : current.keys.length)) break
takeLast(frames)
}
}
return frames.length === 0 ? root : undefined
} catch {
return undefined
}
}
/* jscpd:ignore-end */

View File

@@ -1,8 +1,9 @@
import { describe, expect, it } from 'vitest'
import { EventEmitter } from 'node:events'
import { LogBuffer, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareValue, runWorkerMain, truncateUtf8Bytes, wireReplies } from '../src/bootstrap.ts'
import { LogBuffer, makeBindingErrorClasses, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareCompletion, prepareException, runWorkerMain, wireReplies } from '../src/bootstrap.ts'
import type { BootstrapPort, PatchableStream, PendingCall } from '../src/bootstrap.ts'
import type { ReplyMessage, WorkerToHost } from '../src/protocol.ts'
import { decodeWorkerJson, encodeWorkerJson } from '../src/worker-json.ts'
/**
* An in-process stand-in for the worker's parentPort: the test plays the
@@ -37,25 +38,52 @@ class FakePort implements BootstrapPort {
done(): WorkerToHost | undefined {
return this.sent.find(message => message.type === 'done')
}
doneValue(): unknown {
const done = this.done()
return done?.type === 'done' && done.value !== undefined ? decodeWorkerJson(done.value) : undefined
}
}
function fakeStreams(): { stdout: PatchableStream; stderr: PatchableStream } {
return { stdout: { write: () => true }, stderr: { write: () => true } }
}
const BOOT = { maxLogBytes: 65_536, maxValueBytes: 32_768 }
/** Capture one promise rejection without Vitest's intentionally `any` matcher channel. */
async function rejectionOf(promise: Promise<unknown>): Promise<unknown> {
try {
await promise
return undefined
} catch (error: unknown) {
return error
}
}
const BOOT = { maxOutputBytes: 65_536 }
const TOOL_ERROR_CLASS = { name: 'ToolCallError', memberNameProperty: 'toolName' } as const
/** One worker declaration for the Code Mode tools namespace. */
function toolNamespace(names: string[]) {
return { global: 'tools', names, errorClass: TOOL_ERROR_CLASS }
}
describe('LogBuffer', () => {
it('streams entries to the sink until the byte budget, then emits one marker and drops the rest', () => {
it('streams entries to the sink until the byte budget, then emits one fitting prefix and reports the limit once', () => {
const seen: string[] = []
const buffer = new LogBuffer(10, text => seen.push(text))
let limits = 0
const buffer = new LogBuffer(15, text => seen.push(text), () => { limits += 1 })
buffer.push('12345')
buffer.push('123456')
buffer.push('dropped')
expect(seen).toEqual([
'12345',
'[dsh-code-runtime-worker] log capture truncated at 10 bytes',
])
expect(seen).toEqual(['12345', '123'])
expect(limits).toBe(1)
expect(buffer.remainingOutputBytes()).toBe(0)
const exactlyFull: string[] = []
const fullBuffer = new LogBuffer(6, text => exactlyFull.push(text))
fullBuffer.push('12')
fullBuffer.push('no-prefix-fits')
expect(exactlyFull).toEqual(['12'])
})
})
@@ -109,66 +137,87 @@ describe('captureStreamWrites', () => {
})
})
describe('prepareValue', () => {
it('omits undefined, passes small cloneable values raw', () => {
expect(prepareValue(undefined, 100)).toEqual({})
expect(prepareValue({ a: [1, 'two'] }, 100)).toEqual({ value: { a: [1, 'two'] } })
describe('prepareCompletion', () => {
it('omits undefined and passes lossless JSON values exactly', () => {
expect(prepareCompletion(undefined, 100)).toEqual({})
expect(prepareCompletion({ a: [1, 'two'] }, 100)).toEqual({ value: encodeWorkerJson({ a: [1, 'two'] }) })
})
it('replaces a non-cloneable value with its rendering', () => {
const { value } = prepareValue({ fn: () => 1 }, 1_000)
expect(typeof value).toBe('string')
expect(value).toContain('fn')
it('turns every lossy completion shape into invalid-output', () => {
const cyclic: Record<string, unknown> = {}
cyclic.self = cyclic
const sparse = Array(2)
class Exotic { readonly marker = true }
for (const value of [{ fn: () => 1 }, -0, Number.POSITIVE_INFINITY, sparse, cyclic, new Exotic()]) {
expect(prepareCompletion(value, 1_000)).toEqual({
error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' },
})
}
})
it('replaces an oversized value with a truncation-marked capped rendering', () => {
const { value } = prepareValue('x'.repeat(50), 10)
expect(value).toBe(`${'x'.repeat(10)}… [truncated]`)
it('reports an oversized value instead of substituting rendered text', () => {
expect(prepareCompletion('x'.repeat(50), 10)).toEqual({
error: { kind: 'output-limit', message: 'outer output exceeded 10 bytes' },
})
})
it('measures a container by its structured-clone wire size, not its bounded rendering', () => {
// The bounded inspect rendering of a huge array is tiny ("... N more
// items"), but its real cross-boundary size is not — the cap must catch
// it, replacing the value with that bounded rendering.
const huge = new Array(50_000).fill(7)
const { value } = prepareValue(huge, 1_000)
expect(typeof value).toBe('string')
expect(value).toContain('more items')
it('measures the exact JSON serialization at and over the boundary', () => {
expect(prepareCompletion('€', 5)).toEqual({ value: encodeWorkerJson('€') })
expect(prepareCompletion('€', 4)).toEqual({
error: { kind: 'output-limit', message: 'outer output exceeded 4 bytes' },
})
})
it('caps a multibyte string by UTF-8 bytes, not UTF-16 length', () => {
// 4 code units but 12 UTF-8 bytes: a length-counting cap would pass the
// full string through untruncated.
expect(prepareValue('€€€€', 4)).toEqual({ value: '€… [truncated]' })
it('contains a getter failure as invalid-output', () => {
const value = Object.defineProperty({}, 'x', { enumerable: true, get() { throw new Error('getter exploded') } })
expect(prepareCompletion(value, 1_000)).toEqual({
error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' },
})
})
it('caps a multibyte rendering by UTF-8 bytes too', () => {
// Wire size (24-byte string inside an array) exceeds the cap, so the
// value crosses as its rendering — whose truncation must also be
// byte-exact: "[ '" (3 bytes) + two € (6 bytes) = 9; a third € would
// overflow the 10-byte budget.
expect(prepareValue(['€€€€€€€€'], 10)).toEqual({ value: "[ '€€… [truncated]" })
it('uses the remaining combined budget for invalid-output diagnostics', () => {
expect(prepareCompletion(() => 1, 4, 64)).toEqual({
error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
})
})
})
describe('truncateUtf8Bytes', () => {
it('returns a fitting string whole', () => {
expect(truncateUtf8Bytes('fits', 4)).toBe('fits')
describe('prepareException', () => {
it('passes a fitting diagnostic and rejects one byte over without carrying its text', () => {
expect(prepareException('boom', 6, 64)).toEqual({ error: { kind: 'exception', message: 'boom' } })
expect(prepareException('boom', 5, 64)).toEqual({
error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
})
})
it('cuts at a code-point boundary, never mid-surrogate-pair', () => {
// Each 😀 is one code point, two code units, four UTF-8 bytes: a 5-byte
// budget fits exactly one — and never leaves a lone surrogate behind.
const cut = truncateUtf8Bytes('😀😀', 5)
expect(cut).toBe('😀')
expect(Buffer.byteLength(truncateUtf8Bytes('😀😀', 3), 'utf8')).toBe(0)
it('contains a thrown value whose string conversion fails', () => {
const thrown = { toString() { throw new Error('cannot render') } }
expect(prepareException(thrown, 1_000)).toEqual({
error: { kind: 'exception', message: 'program threw an unrenderable value' },
})
const strangeStack = Object.defineProperty(new Error('ignored'), 'stack', { value: 42 })
expect(prepareException(strangeStack, 1_000)).toEqual({
error: { kind: 'exception', message: '42' },
})
})
})
describe('makeNamespaces', () => {
it('rejects a malformed success reply instead of resolving a lossy binding value', async () => {
const port = new FakePort()
const pending = new Map<number, PendingCall>()
wireReplies(port, pending)
const result = new Promise<unknown>((resolve, reject) => { pending.set(1, { resolve, reject }) })
port.deliver({ type: 'reply', id: 1, ok: true, value: [undefined] as never })
await expect(result).rejects.toThrow('binding resolution must be lossless JSON')
})
it('exposes prototype-colliding names as ordinary own properties', async () => {
const port = new FakePort()
port.respond = message => message.type === 'call' ? { type: 'reply', id: message.id, ok: true, value: `${message.name}-ok` } : undefined
port.respond = message => message.type === 'call'
? { type: 'reply', id: message.id, ok: true, value: encodeWorkerJson(`${message.name}-ok`) }
: undefined
const pending = new Map<number, PendingCall>()
wireReplies(port, pending)
const [tools] = makeNamespaces({ namespaces: [{ global: 'tools', names: ['__proto__', 'constructor', 'toString'] }] }, port, pending, { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
@@ -178,7 +227,7 @@ describe('makeNamespaces', () => {
await expect(tools['toString']?.({})).resolves.toBe('toString-ok')
})
it('rejects a non-cloneable argument without leaking the pending entry', async () => {
it('rejects a postMessage clone failure without leaking the pending entry', async () => {
let firstCall = true
const throwingPort: BootstrapPort = {
// First call throws an Error (the real DataCloneError shape), the
@@ -190,24 +239,108 @@ describe('makeNamespaces', () => {
on: () => {},
}
const pending = new Map<number, PendingCall>()
const [tools] = makeNamespaces({ namespaces: [{ global: 'tools', names: ['x'] }] }, throwingPort, pending, { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
await expect(tools.x?.(() => 1)).rejects.toThrow(/structured-cloneable: DataCloneError-ish/)
await expect(tools.x?.(() => 1)).rejects.toThrow(/structured-cloneable: raw-clone-failure/)
const data = { namespaces: [toolNamespace(['x'])] }
const errorClasses = makeBindingErrorClasses(data)
const ToolCallError = errorClasses.get('tools')
const [tools] = makeNamespaces(
data,
throwingPort,
pending,
{ value: 1 },
errorClasses,
) as [Record<string, (args: unknown) => Promise<unknown>>]
const first = await rejectionOf(tools.x?.({ first: true }) ?? Promise.resolve())
const second = await rejectionOf(tools.x?.({ second: true }) ?? Promise.resolve())
expect(first).toMatchObject({ name: 'ToolCallError', toolName: 'x' })
expect(second).toMatchObject({ name: 'ToolCallError', toolName: 'x' })
expect(first).toBeInstanceOf(ToolCallError)
expect(second).toBeInstanceOf(ToolCallError)
expect((first as Error).message).toMatch(/DataCloneError-ish/)
expect((second as Error).message).toMatch(/raw-clone-failure/)
expect(pending.size).toBe(0)
})
it('rejects lossy arguments before posting or allocating a call id', async () => {
let posts = 0
const port: BootstrapPort = { postMessage: () => { posts += 1 }, on: () => {} }
const pending = new Map<number, PendingCall>()
const nextId = { value: 1 }
const [tools] = makeNamespaces(
{ namespaces: [toolNamespace(['x'])] }, port, pending, nextId,
) as [Record<string, (args: unknown) => Promise<unknown>>]
const decorated = [1]
Object.defineProperty(decorated, 'extra', { value: true })
const throwing = Object.defineProperty({}, 'value', {
enumerable: true,
get: () => { throw new Error('getter exploded') },
})
for (const value of [() => 1, new Date(), decorated, throwing]) {
const failure = await rejectionOf(tools.x?.(value) ?? Promise.resolve())
expect(failure).toMatchObject({
name: 'ToolCallError', toolName: 'x', message: 'binding arguments must be lossless JSON',
})
}
expect(posts).toBe(0)
expect(pending.size).toBe(0)
expect(nextId.value).toBe(1)
})
it('uses ordinary Error for non-tools namespace failures', async () => {
const deniedPort = new FakePort()
deniedPort.respond = message => message.type === 'call'
? { type: 'reply', id: message.id, ok: false, message: 'helper denied' }
: undefined
const deniedPending = new Map<number, PendingCall>()
wireReplies(deniedPort, deniedPending)
const [helpers] = makeNamespaces({ namespaces: [{ global: 'helpers', names: ['x'] }] }, deniedPort, deniedPending, { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
const denied = await rejectionOf(helpers.x?.({}) ?? Promise.resolve())
expect(denied).toBeInstanceOf(Error)
expect(denied).toMatchObject({ name: 'Error', message: 'helper denied' })
expect(denied).not.toHaveProperty('toolName')
const invalid = await rejectionOf(helpers.x?.(() => 1) ?? Promise.resolve())
expect(invalid).toBeInstanceOf(Error)
expect((invalid as Error).message).toBe('binding arguments must be lossless JSON')
const clonePort: BootstrapPort = { postMessage: () => { throw new Error('clone failed') }, on: () => {} }
const [cloneHelpers] = makeNamespaces({ namespaces: [{ global: 'helpers', names: ['x'] }] }, clonePort, new Map(), { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
const cloneFailure = await rejectionOf(cloneHelpers.x?.({}) ?? Promise.resolve())
expect(cloneFailure).toBeInstanceOf(Error)
expect(cloneFailure).not.toHaveProperty('toolName')
})
})
describe('runWorkerMain', () => {
it('runs a program end-to-end: bindings, console, return value', async () => {
const port = new FakePort()
port.respond = message => message.type === 'call' ? { type: 'reply', id: message.id, ok: true, value: (message.args as { n: number }).n * 2 } : undefined
port.respond = (message) => {
if (message.type !== 'call') return undefined
const args = decodeWorkerJson(message.args) as { n: number }
return { type: 'reply', id: message.id, ok: true, value: encodeWorkerJson(args.n * 2) }
}
await runWorkerMain(port, {
...BOOT,
code: 'const doubled = await tools.double({ n: 21 }); console.log("got", doubled); return { doubled };',
namespaces: [{ global: 'tools', names: ['double'] }],
}, fakeStreams())
expect(port.logs()).toEqual(['got 42'])
expect(port.done()).toEqual({ type: 'done', value: { doubled: 42 } })
expect(port.doneValue()).toEqual({ doubled: 42 })
})
it('reports worker-side log capture overflow before completing', async () => {
const port = new FakePort()
await runWorkerMain(port, {
maxOutputBytes: 4,
code: 'console.log("12345"); return null',
namespaces: [],
}, fakeStreams())
expect(port.logs()).toEqual([])
expect(port.sent).toContainEqual({ type: 'output-limit' })
expect(port.done()).toEqual({
type: 'done',
error: { kind: 'output-limit', message: 'outer output exceeded 4 bytes' },
})
})
it('reports a thrown program error on the done message', async () => {
@@ -215,6 +348,7 @@ describe('runWorkerMain', () => {
await runWorkerMain(port, { ...BOOT, code: 'throw new Error("boom")', namespaces: [] }, fakeStreams())
const done = port.done()
expect(done?.type).toBe('done')
expect(done?.type === 'done' ? done.error?.kind : undefined).toBe('exception')
expect(done?.type === 'done' ? done.error?.message : undefined).toContain('boom')
expect(done?.type === 'done' ? done.value : undefined).toBeUndefined()
})
@@ -222,11 +356,35 @@ describe('runWorkerMain', () => {
it('renders non-Error throws and stack-less Errors on the done message', async () => {
const rawPort = new FakePort()
await runWorkerMain(rawPort, { ...BOOT, code: 'throw "raw-throw"', namespaces: [] }, fakeStreams())
expect(rawPort.done()).toEqual({ type: 'done', error: { message: 'raw-throw' } })
expect(rawPort.done()).toEqual({ type: 'done', error: { kind: 'exception', message: 'raw-throw' } })
const barePort = new FakePort()
await runWorkerMain(barePort, { ...BOOT, code: 'const e = new Error("bare"); e.stack = undefined; throw e', namespaces: [] }, fakeStreams())
expect(barePort.done()).toEqual({ type: 'done', error: { message: 'bare' } })
expect(barePort.done()).toEqual({ type: 'done', error: { kind: 'exception', message: 'bare' } })
})
it('replaces giant thrown strings and Error stacks before posting the done message', async () => {
const rawPort = new FakePort()
await runWorkerMain(rawPort, {
maxOutputBytes: 64,
code: 'throw "x".repeat(1_000_000)',
namespaces: [],
}, fakeStreams())
expect(rawPort.done()).toEqual({
type: 'done',
error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
})
const stackPort = new FakePort()
await runWorkerMain(stackPort, {
maxOutputBytes: 64,
code: 'throw new Error("x".repeat(1_000_000))',
namespaces: [],
}, fakeStreams())
expect(stackPort.done()).toEqual({
type: 'done',
error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
})
})
it('surfaces a host failure reply as a program-side rejection it can catch', async () => {
@@ -234,10 +392,27 @@ describe('runWorkerMain', () => {
port.respond = message => message.type === 'call' ? { type: 'reply', id: message.id, ok: false, message: 'denied by host' } : undefined
await runWorkerMain(port, {
...BOOT,
code: 'try { await tools.x({}) } catch (error) { return `caught: ${error.message}` }',
namespaces: [{ global: 'tools', names: ['x'] }],
code: 'try { await tools.x({}) } catch (error) { return { caught: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message } }',
namespaces: [toolNamespace(['x'])],
}, fakeStreams())
expect(port.done()).toEqual({ type: 'done', value: 'caught: denied by host' })
expect(port.doneValue()).toEqual({ caught: true, name: 'ToolCallError', toolName: 'x', message: 'denied by host' })
})
it('materializes a consumer-declared rejection class without knowing the namespace', async () => {
const port = new FakePort()
port.respond = message => message.type === 'call'
? { type: 'reply', id: message.id, ok: false, message: 'helper denied' }
: undefined
await runWorkerMain(port, {
...BOOT,
code: 'try { await helpers.x({}) } catch (error) { return { caught: error instanceof HelperCallError, name: error.name, helperName: error.helperName, message: error.message } }',
namespaces: [{
global: 'helpers',
names: ['x'],
errorClass: { name: 'HelperCallError', memberNameProperty: 'helperName' },
}],
}, fakeStreams())
expect(port.doneValue()).toEqual({ caught: true, name: 'HelperCallError', helperName: 'x', message: 'helper denied' })
})
it('ignores replies for unknown pending ids', async () => {
@@ -245,15 +420,15 @@ describe('runWorkerMain', () => {
port.respond = (message) => {
if (message.type !== 'call') return undefined
// Deliver a stray reply first; the real one follows.
port.deliver({ type: 'reply', id: 9_999, ok: true, value: 'stray' })
return { type: 'reply', id: message.id, ok: true, value: 'real' }
port.deliver({ type: 'reply', id: 9_999, ok: true, value: encodeWorkerJson('stray') })
return { type: 'reply', id: message.id, ok: true, value: encodeWorkerJson('real') }
}
await runWorkerMain(port, {
...BOOT,
code: 'return await tools.x({})',
namespaces: [{ global: 'tools', names: ['x'] }],
}, fakeStreams())
expect(port.done()).toEqual({ type: 'done', value: 'real' })
expect(port.doneValue()).toBe('real')
})
it('captures raw stream writes through the patched process streams', async () => {

View File

@@ -23,8 +23,15 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
const ctx = new Context()
await ctx.plugin(WorkerCodeRuntime, {})
const result = await ctx.codeRuntime.run({
program: 'const doubled: number = await tools.double({ n: 21 }); console.log("halfway", doubled); return doubled;',
bindings: [{ global: 'tools', functions: { double: async args => args.n * 2 } }],
program: 'const doubled: number = await tools.double({ n: 21 }); console.log("halfway", doubled); let failure; try { await tools.fail({}) } catch (error) { failure = { typed: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message } } return { doubled, failure };',
bindings: [{
global: 'tools',
functions: {
double: async args => args.n * 2,
fail: async () => { throw new Error('denied') },
},
errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' },
}],
})
console.log(JSON.stringify(result))
process.exit(0)
@@ -40,7 +47,10 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
const lastLine = stdout.trim().split('\n').at(-1) ?? ''
const result = JSON.parse(lastLine) as { value?: unknown; logs: string[]; error?: unknown }
expect(result.error).toBeUndefined()
expect(result.value).toBe(42)
expect(result.value).toEqual({
doubled: 42,
failure: { typed: true, name: 'ToolCallError', toolName: 'fail', message: 'denied' },
})
expect(result.logs).toContain('halfway 42')
})
})

View File

@@ -0,0 +1,109 @@
import { describe, expect, it, vi } from 'vitest'
import type { CodeJsonValue } from '@deepseek-ai/dsh-code-runtime'
import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from '../src/output-json.ts'
describe('truncateJsonStringBytes', () => {
it('returns a fitting string whole and rejects budgets without JSON quotes', () => {
expect(truncateJsonStringBytes('fits', 6)).toBe('fits')
expect(truncateJsonStringBytes('x', 1)).toBe('')
expect(jsonStringBytesUpTo('fits', 6)).toBe(6)
expect(jsonStringBytesUpTo('fits', 5)).toBeUndefined()
})
it('accounts every JSON escape and cuts only between complete code points', () => {
const prefix = '"\\\b\t\n\f\r\u0000😀\ud800€a'
const text = `${prefix}z`
const budget = Buffer.byteLength(JSON.stringify(prefix), 'utf8')
expect(truncateJsonStringBytes(text, budget)).toBe(prefix)
expect(Buffer.byteLength(JSON.stringify(truncateJsonStringBytes(text, budget)), 'utf8')).toBe(budget)
})
it('bounds hostile strings without materializing their complete escaped form', () => {
const stringify = vi.spyOn(JSON, 'stringify').mockImplementation(() => { throw new Error('must not stringify') })
try {
expect(jsonStringBytesUpTo('"'.repeat(10_000), 32)).toBeUndefined()
expect(truncateJsonStringBytes('"'.repeat(10_000), 32)).toBe('"'.repeat(15))
} finally {
stringify.mockRestore()
}
})
})
describe('jsonValueBytesUpTo', () => {
it('matches JSON serialization for every lossless value branch and stops at the cap', () => {
const value = {
empty: {},
nil: null,
yes: true,
no: false,
number: 1.5,
text: '"\n😀',
array: [1, 'x'],
}
const bytes = Buffer.byteLength(JSON.stringify(value), 'utf8')
expect(jsonValueBytesUpTo(value, bytes)).toBe(bytes)
expect(jsonValueBytesUpTo(value, bytes - 1)).toBeUndefined()
expect(jsonValueBytesUpTo({}, 1)).toBeUndefined()
expect(jsonValueBytesUpTo([], 1)).toBeUndefined()
expect(jsonValueBytesUpTo([], 2)).toBe(2)
expect(jsonValueBytesUpTo(null, 3)).toBeUndefined()
expect(jsonValueBytesUpTo(10, 1)).toBeUndefined()
expect(jsonValueBytesUpTo(false, 4)).toBeUndefined()
expect(jsonValueBytesUpTo(new Array<never>(1), 10)).toBeUndefined()
expect(jsonValueBytesUpTo([null], 5)).toBeUndefined()
expect(jsonValueBytesUpTo([0, 0], 3)).toBeUndefined()
expect(jsonValueBytesUpTo({ a: null, b: null }, 10)).toBeUndefined()
expect(jsonValueBytesUpTo({ long: null }, 2)).toBeUndefined()
expect(jsonValueBytesUpTo({ '': null }, 4)).toBeUndefined()
expect(jsonValueBytesUpTo({ a: null }, 9)).toBeUndefined()
expect(jsonValueBytesUpTo({ a: undefined } as unknown as CodeJsonValue, 100)).toBeUndefined()
})
it('meters deeply nested arrays without recursive stack growth', () => {
let value: CodeJsonValue = null
for (let depth = 0; depth < 5_000; depth++) value = [value]
expect(jsonValueBytesUpTo(value, 10_004)).toBe(10_004)
expect(jsonValueBytesUpTo(value, 10_003)).toBeUndefined()
})
it('uses module-captured intrinsics after model-visible globals are mutated', () => {
const value: CodeJsonValue = { payload: ['€', 42] }
const bytes = Buffer.byteLength(JSON.stringify(value), 'utf8')
const arrayIsArrayDescriptor = Object.getOwnPropertyDescriptor(Array, 'isArray')!
const arrayPopDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'pop')!
const arrayPushDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'push')!
const byteLengthDescriptor = Object.getOwnPropertyDescriptor(Buffer, 'byteLength')!
const objectKeysDescriptor = Object.getOwnPropertyDescriptor(Object, 'keys')!
const charCodeAtDescriptor = Object.getOwnPropertyDescriptor(String.prototype, 'charCodeAt')!
const codePointAtDescriptor = Object.getOwnPropertyDescriptor(String.prototype, 'codePointAt')!
const sliceDescriptor = Object.getOwnPropertyDescriptor(String.prototype, 'slice')!
let measured: number | undefined
let prefix = ''
try {
Array.isArray = (_value: unknown): _value is never[] => false
Array.prototype.pop = () => { throw new Error('mutated pop') }
Array.prototype.push = () => { throw new Error('mutated push') }
Buffer.byteLength = () => 0
Object.keys = () => []
String.prototype.charCodeAt = () => { throw new Error('mutated charCodeAt') }
String.prototype.codePointAt = () => { throw new Error('mutated codePointAt') }
String.prototype.slice = () => { throw new Error('mutated slice') }
measured = jsonValueBytesUpTo(value, bytes)
prefix = truncateJsonStringBytes('€x', 5)
} finally {
Object.defineProperty(Array, 'isArray', arrayIsArrayDescriptor)
Object.defineProperty(Array.prototype, 'pop', arrayPopDescriptor)
Object.defineProperty(Array.prototype, 'push', arrayPushDescriptor)
Object.defineProperty(Buffer, 'byteLength', byteLengthDescriptor)
Object.defineProperty(Object, 'keys', objectKeysDescriptor)
Object.defineProperty(String.prototype, 'charCodeAt', charCodeAtDescriptor)
Object.defineProperty(String.prototype, 'codePointAt', codePointAtDescriptor)
Object.defineProperty(String.prototype, 'slice', sliceDescriptor)
}
expect(measured).toBe(bytes)
expect(prefix).toBe('€')
})
})

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker'
import type { Config } from '@deepseek-ai/dsh-code-runtime-worker'
import type { CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import type { CodeBindingFunction, CodeBindingNamespace, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
/**
* Integration suite over REAL worker threads (no mocks — workers are cheap
@@ -17,8 +17,12 @@ async function setup(config: Config = {}) {
}
/** Convenience: one namespace `tools` with the given functions. */
function tools(functions: Record<string, (args: unknown) => Promise<unknown>>) {
return [{ global: 'tools', functions }]
function tools(functions: Record<string, (args: unknown) => Promise<unknown>>): CodeBindingNamespace[] {
return [{
global: 'tools',
functions: functions as Record<string, CodeBindingFunction>,
errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' },
}]
}
describe('WorkerCodeRuntime — programs and bindings (real workers)', () => {
@@ -52,10 +56,10 @@ describe('WorkerCodeRuntime — programs and bindings (real workers)', () => {
const result = await runtime.run({
program: `
const first = await tools.echo({ n: 1 });
let caught = '';
try { await tools.fail({}) } catch (error) { caught = error.message }
let caughtRaw = '';
try { await tools.failRaw({}) } catch (error) { caughtRaw = error.message }
let caught = {};
try { await tools.fail({}) } catch (error) { caught = { isTyped: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message } }
let caughtRaw = {};
try { await tools.failRaw({}) } catch (error) { caughtRaw = { name: error.name, toolName: error.toolName, message: error.message } }
return { first, caught, caughtRaw };
`,
bindings: tools({
@@ -66,10 +70,61 @@ describe('WorkerCodeRuntime — programs and bindings (real workers)', () => {
}),
})
expect(result.error).toBeUndefined()
expect(result.value).toEqual({ first: { echoed: { n: 1 } }, caught: 'nope', caughtRaw: 'raw-nope' })
expect(result.value).toEqual({
first: { echoed: { n: 1 } },
caught: { isTyped: true, name: 'ToolCallError', toolName: 'fail', message: 'nope' },
caughtRaw: { name: 'ToolCallError', toolName: 'failRaw', message: 'raw-nope' },
})
expect(calls).toEqual([{ n: 1 }])
})
it('materializes a typed rejection from a generic namespace descriptor', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: `
try { await helpers.fail({}) } catch (error) {
return {
isTyped: error instanceof HelperCallError,
name: error.name,
helperName: error.helperName,
message: error.message,
};
}
`,
bindings: [{
global: 'helpers',
functions: { fail: async () => { throw new Error('nope') } },
errorClass: { name: 'HelperCallError', memberNameProperty: 'helperName' },
}],
})
expect(result.value).toEqual({
isTyped: true,
name: 'HelperCallError',
helperName: 'fail',
message: 'nope',
})
})
it('bridges a deeply nested lossless JSON argument, resolution, and completion', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: `
let value = 'leaf';
for (let depth = 0; depth < 3_000; depth++) value = [value];
return await tools.echo(value);
`,
bindings: tools({ echo: async args => args }),
})
expect(result.error).toBeUndefined()
let cursor = result.value
for (let depth = 0; depth < 3_000; depth++) {
expect(Array.isArray(cursor)).toBe(true)
cursor = Array.isArray(cursor) ? cursor[0] : undefined
}
expect(cursor).toBe('leaf')
}, 15_000)
it('reports non-erasable syntax as an exception without spawning a worker', async () => {
const { runtime } = await setup()
const result = await runtime.run({ program: 'enum E { A }\nreturn 1', bindings: [] })
@@ -90,10 +145,11 @@ describe('WorkerCodeRuntime — programs and bindings (real workers)', () => {
expect(result.value).toBe('{}')
})
it('replaces a non-cloneable return value with a string rendering', async () => {
it('rejects a non-lossless completion instead of replacing it with rendered text', async () => {
const { runtime } = await setup()
const result = await runtime.run({ program: 'return { f: () => 1 }', bindings: [] })
expect(typeof result.value).toBe('string')
expect(result.value).toBeUndefined()
expect(result.error).toEqual({ kind: 'invalid-output', message: 'program completion must be lossless JSON' })
})
it('completes a program that returns nothing with no value at all', async () => {
@@ -166,6 +222,19 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => {
expect(result.error).toEqual({ kind: 'abort', message: 'too-late' })
})
it('applies the outer-output cap to failures before worker startup', async () => {
const capped = await setup({ maxOutputBytes: 64 })
const controller = new AbortController()
controller.abort('A'.repeat(1_000))
const aborted = await capped.runtime.run({ program: 'return 1', bindings: [], signal: controller.signal })
expect(aborted).toEqual({ logs: [], error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' } })
const minimal = await setup({ maxOutputBytes: 4 })
const invalid = await minimal.runtime.run({ program: 'enum E { A }\nreturn 1', bindings: [] })
expect(invalid.error?.kind).toBe('output-limit')
expect(Buffer.byteLength(JSON.stringify(invalid.logs), 'utf8') + Buffer.byteLength(JSON.stringify(invalid.error?.message), 'utf8')).toBeLessThanOrEqual(4)
})
it('drops a binding resolution that lands after the run settled', async () => {
const { runtime } = await setup()
const controller = new AbortController()
@@ -201,30 +270,94 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => {
expect(after.value).toBe('alive')
}, 30_000)
it('truncates runaway log output at the byte budget with an in-band marker', async () => {
const { runtime } = await setup({ maxLogBytes: 300 })
it('reports a worker that exits before publishing a completion', async () => {
const { runtime } = await setup()
const result = await runtime.run({ program: 'process.exit(7)', bindings: [] })
expect(result).toEqual({
logs: [],
error: { kind: 'worker-exit', message: 'worker exited with code 7 before completing' },
})
})
it('fails runaway log output explicitly while retaining a bounded prefix', async () => {
const { runtime } = await setup({ maxOutputBytes: 300 })
const result = await runtime.run({
program: 'for (let i = 0; i < 1000; i++) console.log("spam line", i); return 1',
bindings: [],
})
expect(result.logs.at(-1)).toContain('truncated at 300 bytes')
const total = result.logs.reduce((sum, text) => sum + Buffer.byteLength(text, 'utf8'), 0)
expect(total).toBeLessThan(1_000)
expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 300 bytes' })
expect(result.value).toBeUndefined()
expect(result.logs.length).toBeGreaterThan(0)
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')).toBeLessThan(300)
})
it('caps an oversized return value with a truncation marker', async () => {
const { runtime } = await setup({ maxValueBytes: 64 })
it('retains a fitting prefix when one oversized log is the first output', async () => {
const { runtime } = await setup({ maxOutputBytes: 96 })
const result = await runtime.run({
program: 'console.log(`start-${`😀"\\\\\\n`.repeat(100)}`); return null',
bindings: [],
})
expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 96 bytes' })
expect(result.logs).toHaveLength(1)
expect(result.logs[0]?.startsWith('start-')).toBe(true)
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')
+ Buffer.byteLength(JSON.stringify(result.error?.message), 'utf8')).toBeLessThanOrEqual(96)
})
it('fails an oversized return value without substituting a string', async () => {
const { runtime } = await setup({ maxOutputBytes: 64 })
const result = await runtime.run({ program: 'return "y".repeat(10_000)', bindings: [] })
expect(result.value).toBe(`${'y'.repeat(64)}… [truncated]`)
expect(result.value).toBeUndefined()
expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 64 bytes' })
})
it('caps a multibyte return value by UTF-8 bytes, not string length', async () => {
// 4 code units, 12 UTF-8 bytes: a length-counting cap would let the full
// string cross. The worker's byte-exact capped rendering then passes the
// host re-cap unchanged (cap + marker is exactly the granted slack).
const { runtime } = await setup({ maxValueBytes: 4 })
const result = await runtime.run({ program: 'return "€€€€"', bindings: [] })
expect(result.value).toBe('€… [truncated]')
it('uses UTF-8 serialized bytes at the exact completion boundary', async () => {
const exact = await setup({ maxOutputBytes: 7 })
const exactResult = await exact.runtime.run({ program: 'return "€"', bindings: [] })
// [] costs two bytes and JSON serialization of "€" costs five.
expect(exactResult).toEqual({ logs: [], value: '€' })
const over = await setup({ maxOutputBytes: 6 })
const overResult = await over.runtime.run({ program: 'return "€"', bindings: [] })
expect(overResult.error?.kind).toBe('output-limit')
})
it('accounts logs and completion in one exact combined ledger', async () => {
// JSON(["abc"]) is seven bytes and JSON("xy") is four.
const exact = await setup({ maxOutputBytes: 11 })
expect(await exact.runtime.run({ program: 'console.log("abc"); return "xy"', bindings: [] }))
.toEqual({ logs: ['abc'], value: 'xy' })
const over = await setup({ maxOutputBytes: 10 })
const result = await over.runtime.run({ program: 'console.log("abc"); return "xy"', bindings: [] })
expect(result.value).toBeUndefined()
expect(result.error?.kind).toBe('output-limit')
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8') + Buffer.byteLength(JSON.stringify(result.error?.message), 'utf8')).toBeLessThanOrEqual(10)
})
it('accounts logs and exception diagnostics before the worker port boundary', async () => {
// JSON(["abc"]) is seven bytes and JSON("xy") is four.
const exact = await setup({ maxOutputBytes: 11 })
expect(await exact.runtime.run({ program: 'console.log("abc"); throw "xy"', bindings: [] }))
.toEqual({ logs: ['abc'], error: { kind: 'exception', message: 'xy' } })
const over = await setup({ maxOutputBytes: 10 })
const result = await over.runtime.run({ program: 'console.log("abc"); throw "xy"', bindings: [] })
expect(result.error?.kind).toBe('output-limit')
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')
+ Buffer.byteLength(JSON.stringify(result.error?.message), 'utf8')).toBeLessThanOrEqual(10)
})
it('does not send a giant Error stack across the worker port', async () => {
const { runtime } = await setup({ maxOutputBytes: 64 })
const result = await runtime.run({
program: 'throw new Error("x".repeat(1_000_000))',
bindings: [],
})
expect(result).toEqual({
logs: [],
error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
})
})
it('completes a program that awaits its write callback, capturing the chunk', async () => {
@@ -241,32 +374,67 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => {
expect(result.logs).toContain('flushed')
})
it('caps a huge container whose bounded rendering is small (wire size, not rendering, is what counts)', async () => {
it('returns a large JSON container exactly when the outer cap permits it', async () => {
const { runtime } = await setup()
const result = await runtime.run({ program: 'return new Array(50_000).fill(7)', bindings: [] })
expect(result.error).toBeUndefined()
expect(typeof result.value).toBe('string')
expect(result.value).toContain('more items')
expect(result.value).toEqual(new Array(50_000).fill(7))
})
it('captures pipe writes that bypass the patched write slot as stray logs, capped by the same budget', async () => {
const { runtime } = await setup({ maxLogBytes: 4 })
it('returns an exact completion at the default 64 MiB combined boundary', async () => {
const { runtime } = await setup()
// [] costs two bytes and the JSON string contributes two quotes, leaving
// exactly this many payload bytes under the 67_108_864-byte default.
const result = await runtime.run({ program: 'return "x".repeat(67_108_860)', bindings: [] })
expect(result.error).toBeUndefined()
expect(result.logs).toEqual([])
expect(result.value).toHaveLength(67_108_860)
}, 60_000)
it('fails one byte over the default 64 MiB combined boundary', async () => {
const { runtime } = await setup()
const result = await runtime.run({ program: 'return "x".repeat(67_108_861)', bindings: [] })
expect(result.value).toBeUndefined()
expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 67108864 bytes' })
}, 60_000)
it('accounts pipe writes that bypass the patched write slot in the same outer ledger', async () => {
const { runtime } = await setup({ maxOutputBytes: 80 })
const result = await runtime.run({
// The prototype write bypasses the patched instance and reaches the real pipe. Pauses keep
// writes in separate chunks and let both reach the host before settlement.
program: `
const write = (text) => Object.getPrototypeOf(process.stdout).write.call(process.stdout, text);
write('abcd');
write('a'.repeat(20));
await new Promise(resolve => setTimeout(resolve, 150));
write('ef');
write('b'.repeat(100));
await new Promise(resolve => setTimeout(resolve, 100));
return 1;
`,
bindings: [],
})
expect(result.error?.kind).toBe('output-limit')
expect(result.logs).toContain('a'.repeat(20))
expect(result.logs[1]?.length).toBeGreaterThan(0)
expect('b'.repeat(100).startsWith(result.logs[1] ?? '')).toBe(true)
}, 15_000)
it('drains pipe output queued before terminal worker teardown completes', async () => {
const { runtime } = await setup({ maxOutputBytes: 200_000 })
const payload = `late-pipe-${'x'.repeat(100_000)}`
const result = await runtime.run({
program: `
const { parentPort } = await import('node:worker_threads');
const write = (text) => Object.getPrototypeOf(process.stdout).write.call(process.stdout, text);
write('late-pipe-' + 'x'.repeat(100_000));
parentPort.postMessage({ type: 'done', value: ['done'] });
for (;;) {}
`,
bindings: [],
})
expect(result.error).toBeUndefined()
expect(result.logs).toContain('abcd')
expect(result.logs).not.toContain('ef')
expect(result.value).toBe('done')
expect(result.logs.join('') === payload).toBe(true)
}, 15_000)
})
@@ -305,7 +473,8 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => {
{ type: 'log', text: 7 },
{ type: 'log', text: {} },
{ type: 'done', error: 5 },
{ type: 'done', error: { message: 5 } },
{ type: 'done', error: { kind: 'exception', message: 5 } },
{ type: 'done', error: { kind: 'invented', message: 'bad kind' } },
]) parentPort.postMessage(junk);
return await tools.real({});
`,
@@ -316,67 +485,288 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => {
expect(result.logs).toEqual([])
})
it('caps forged log floods and forged done values at the configured budgets, dropping forged extra fields', async () => {
const { runtime } = await setup({ maxLogBytes: 200, maxValueBytes: 64 })
it('fails forged log floods and forged done values through the same outer cap', async () => {
const { runtime } = await setup({ maxOutputBytes: 200 })
const result = await runtime.run({
// Forged messages bypass the worker-side LogBuffer and prepareValue
// Forged messages bypass the worker-side LogBuffer and completion check
// entirely — only the host-side ledger and re-cap stand between model
// code and an unbounded result.
program: `
const { parentPort } = await import('node:worker_threads');
for (let i = 0; i < 50; i++) parentPort.postMessage({ type: 'log', text: 'F'.repeat(100), forged: true });
parentPort.postMessage({ type: 'done', value: 'V'.repeat(100000) });
parentPort.postMessage({ type: 'done', value: ['V'.repeat(100000)] });
for (;;) {}
`,
bindings: [],
})
expect(typeof result.value).toBe('string')
const value = result.value as string
expect(value.startsWith('V'.repeat(64))).toBe(true)
expect(value.endsWith('… [truncated]')).toBe(true)
expect(value.length).toBeLessThan(120)
const marker = '[dsh-code-runtime-worker] log capture truncated at 200 bytes'
const total = result.logs.reduce((sum, text) => sum + Buffer.byteLength(text, 'utf8'), 0)
expect(total).toBeLessThanOrEqual(200 + Buffer.byteLength(marker, 'utf8'))
expect(result.logs.at(-1)).toBe(marker)
expect(result.value).toBeUndefined()
expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 200 bytes' })
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')).toBeLessThan(200)
})
it('accepts a forged done carrying both value and error (self-sabotage, contained)', async () => {
it('re-caps an oversized forged done value at the host boundary', async () => {
const { runtime } = await setup({ maxOutputBytes: 64 })
const result = await runtime.run({
program: `
const { parentPort } = await import('node:worker_threads');
parentPort.postMessage({ type: 'done', value: ['V'.repeat(100_000)] });
for (;;) {}
`,
bindings: [],
})
expect(result).toEqual({
logs: [],
error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
})
})
it('bounds one oversized forged log while retaining its fitting escaped prefix', async () => {
const { runtime } = await setup({ maxOutputBytes: 96 })
const result = await runtime.run({
program: `
const { parentPort } = await import('node:worker_threads');
parentPort.postMessage({ type: 'log', text: '"'.repeat(1_000_000) });
for (;;) {}
`,
bindings: [],
})
expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 96 bytes' })
expect(result.logs).toHaveLength(1)
expect(result.logs[0]).toMatch(/^"+$/)
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8') + Buffer.byteLength(JSON.stringify('outer output exceeded 96 bytes'), 'utf8')).toBeLessThanOrEqual(96)
})
it('drops a malformed forged done carrying both value and error', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: `
const { parentPort } = await import('node:worker_threads');
parentPort.postMessage({ type: 'done', value: 'lied', error: { message: 'fake failure' } });
for (;;) {}
parentPort.postMessage({ type: 'done', value: 'lied', error: { kind: 'exception', message: 'fake failure' } });
return 'honest';
`,
bindings: [],
})
expect(result.value).toBe('lied')
expect(result.error).toEqual({ kind: 'exception', message: 'fake failure' })
expect(result).toEqual({ logs: [], error: { kind: 'exception', message: 'fake failure' } })
})
it('byte-bounds forged multibyte error text at the host', async () => {
// Forged error text bypasses the worker entirely; the host bound is a
// BYTE bound (two € = 6 bytes fit an 8-byte cap, a third would not).
const { runtime } = await setup({ maxValueBytes: 8 })
it('contains a deeply nested forged completion without overflowing the host meter', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: `
const { parentPort } = await import('node:worker_threads');
parentPort.postMessage({ type: 'done', error: { message: '€'.repeat(1000) } });
const value = [];
for (let depth = 0; depth < 3_000; depth++) value.push({ kind: 'array', length: 1 });
value.push(null);
setTimeout(() => { parentPort.postMessage({ type: 'done', value }) }, 25);
// Prevent bootstrap's normal undefined completion from racing the forged terminal.
await new Promise(() => {});
`,
bindings: [],
})
expect(result.error).toBeUndefined()
let value = result.value
let depth = 0
while (Array.isArray(value)) {
expect(value).toHaveLength(1)
value = value[0]
depth += 1
}
expect(depth).toBe(3_000)
expect(value).toBeNull()
}, 15_000)
it('turns forged over-limit error text into output-limit at the host', async () => {
const { runtime } = await setup({ maxOutputBytes: 64 })
const result = await runtime.run({
program: `
const { parentPort } = await import('node:worker_threads');
parentPort.postMessage({ type: 'done', error: { kind: 'exception', message: '€'.repeat(1000) } });
for (;;) {}
`,
bindings: [],
})
expect(result.error).toEqual({ kind: 'exception', message: '€€' })
expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 64 bytes' })
})
it('answers a binding whose resolution cannot be cloned with a failure reply', async () => {
it('answers a binding whose resolution is not lossless JSON with a typed failure reply', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: 'try { await tools.bad({}) } catch (error) { return error.message }',
program: 'try { await tools.bad({}) } catch (error) { return { name: error.name, toolName: error.toolName, message: error.message } }',
bindings: tools({ bad: async () => (() => 1) }),
})
expect(result.value).toContain('not structured-cloneable')
expect(result.value).toEqual({ name: 'ToolCallError', toolName: 'bad', message: 'binding resolution must be lossless JSON' })
})
it('rejects lossy binding arguments in the worker before invoking the host binding', async () => {
const { runtime } = await setup()
let calls = 0
const result = await runtime.run({
program: `
const decorated = [1]; Object.defineProperty(decorated, 'extra', { value: true });
const values = [new Date(), decorated, () => 1];
const failures = [];
for (const value of values) {
try { await tools.never(value) } catch (error) {
failures.push({ typed: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message });
}
}
return failures;
`,
bindings: tools({ never: async () => { calls += 1; return null } }),
})
expect(calls).toBe(0)
expect(result.value).toEqual(new Array(3).fill({
typed: true,
name: 'ToolCallError',
toolName: 'never',
message: 'binding arguments must be lossless JSON',
}))
})
it('rejects intrinsic-looking exotic objects as arguments and completions', async () => {
const { runtime } = await setup()
let calls = 0
const forgeObject = `
const prototype = Object.create(null);
const SpoofedObject = function Object() {};
SpoofedObject.prototype = prototype;
Object.defineProperty(prototype, 'constructor', { value: SpoofedObject });
const forged = Object.assign(Object.create(prototype), { value: 1 });
Function.prototype.toString = () => 'function Object() { [native code] }';
`
const argument = await runtime.run({
program: `${forgeObject}
try { await tools.never(forged) } catch (error) {
return { typed: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message };
}
`,
bindings: tools({ never: async () => { calls += 1; return null } }),
})
expect(calls).toBe(0)
expect(argument.value).toEqual({
typed: true,
name: 'ToolCallError',
toolName: 'never',
message: 'binding arguments must be lossless JSON',
})
const completion = await runtime.run({ program: `${forgeObject}\nreturn forged`, bindings: [] })
expect(completion).toEqual({
logs: [],
error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' },
})
})
it('preserves binding and completion JSON after model code mutates boundary globals', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: `
const arrayPrototype = Array.prototype;
const objectPrototype = Object.prototype;
const setPrototype = Set.prototype;
const stringPrototype = String.prototype;
Array.isArray = () => false;
arrayPrototype.at = arrayPrototype.includes = arrayPrototype.pop = arrayPrototype.push = () => { throw new Error('mutated array method') };
Object.defineProperty = Object.getOwnPropertyDescriptor = Object.getPrototypeOf = Object.keys = () => { throw new Error('mutated object method') };
Object.hasOwn = () => false;
Object.is = () => true;
objectPrototype.propertyIsEnumerable = () => false;
Number.isFinite = Number.isSafeInteger = () => false;
Reflect.apply = Reflect.ownKeys = () => { throw new Error('mutated reflect method') };
setPrototype.add = setPrototype.delete = setPrototype.has = () => { throw new Error('mutated set method') };
stringPrototype.charCodeAt = stringPrototype.codePointAt = stringPrototype.slice = () => { throw new Error('mutated string method') };
Buffer.byteLength = () => 0;
Function.prototype.toString = () => 'mutated';
objectPrototype.get = () => undefined;
objectPrototype.constructor = arrayPrototype.constructor = null;
globalThis.Array = globalThis.Buffer = globalThis.Error = globalThis.Function = globalThis.Number = globalThis.Object = globalThis.Reflect = globalThis.Set = globalThis.String = undefined;
const echoed = await tools.echo({ request: ['€', 1] });
let failure;
try { await tools.fail({}) } catch (error) {
failure = { typed: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message };
}
return { echoed, failure, completion: { ok: true, amount: 42 } };
`,
bindings: tools({ echo: async args => args, fail: async () => { throw new Error('nope') } }),
})
expect(result).toEqual({
logs: [],
value: {
echoed: { request: ['€', 1] },
failure: { typed: true, name: 'ToolCallError', toolName: 'fail', message: 'nope' },
completion: { ok: true, amount: 42 },
},
})
})
it('rejects forged lossy binding arguments again at the host boundary', async () => {
const { runtime } = await setup()
let calls = 0
const result = await runtime.run({
program: `
const { parentPort } = await import('node:worker_threads');
const forged = (id, args) => new Promise((resolve) => {
const receive = (message) => {
if (message?.type !== 'reply' || message.id !== id) return;
parentPort.off('message', receive);
resolve(message);
};
parentPort.on('message', receive);
parentPort.postMessage({ type: 'call', id, global: 'tools', name: 'never', args });
});
const sparse = []; sparse.length = 1;
const cycle = {}; cycle.self = cycle;
return await Promise.all([
forged(8001, new Date()),
forged(8002, -0),
forged(8003, sparse),
forged(8004, cycle),
]);
`,
bindings: tools({ never: async () => { calls += 1; return null } }),
})
expect(calls).toBe(0)
expect(result.value).toEqual([8001, 8002, 8003, 8004].map(id => ({
type: 'reply',
id,
ok: false,
message: 'binding arguments must be lossless JSON',
})))
})
it('contains throwing getters while snapshotting binding resolutions', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: 'try { await tools.bad({}) } catch (error) { return { name: error.name, toolName: error.toolName, message: error.message } }',
bindings: tools({ bad: async () => Object.defineProperty({}, 'bad', { enumerable: true, get() { throw new Error('getter exploded') } }) }),
})
expect(result.value).toEqual({ name: 'ToolCallError', toolName: 'bad', message: 'binding resolution must be lossless JSON' })
})
it('revalidates a forged lossy completion at the host boundary', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: `
const { parentPort } = await import('node:worker_threads');
parentPort.postMessage({ type: 'done', value: -0 });
for (;;) {}
`,
bindings: [],
})
expect(result).toEqual({ logs: [], error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' } })
})
it('honors a forged worker-side output-limit signal', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: `
const { parentPort } = await import('node:worker_threads');
parentPort.postMessage({ type: 'output-limit' });
for (;;) {}
`,
bindings: [],
})
expect(result).toEqual({ logs: [], error: { kind: 'output-limit', message: 'outer output exceeded 67108864 bytes' } })
})
it('exposes binding names that collide with Object.prototype as ordinary functions', async () => {
@@ -392,7 +782,7 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => {
})
describe('WorkerCodeRuntime — seam misuse and lifecycle', () => {
it('rejects invalid binding globals loudly (identifier, reserved word, duplicate, console)', async () => {
it('rejects invalid and duplicate binding globals loudly', async () => {
const { runtime } = await setup()
const cases: [string, RegExp][] = [
['not valid!', /not a usable identifier/],
@@ -406,6 +796,32 @@ describe('WorkerCodeRuntime — seam misuse and lifecycle', () => {
program: 'return 1',
bindings: [{ global: 'tools', functions: {} }, { global: 'tools', functions: {} }],
})).rejects.toThrow(/duplicate binding global/)
await expect(runtime.run({
program: 'return typeof ToolCallError',
bindings: [{ global: 'ToolCallError', functions: {} }],
})).resolves.toMatchObject({ value: 'object' })
})
it('rejects malformed or colliding binding error-class declarations', async () => {
const { runtime } = await setup()
const run = async (bindings: CodeBindingNamespace[]) => await runtime.run({ program: 'return 1', bindings })
const namespace = (global: string, name: string, memberNameProperty = 'memberName'): CodeBindingNamespace => ({
global,
functions: {},
errorClass: { name, memberNameProperty },
})
await expect(run([namespace('tools', 'not valid!')])).rejects.toThrow(/error class.*not a usable identifier/)
await expect(run([namespace('tools', 'await')])).rejects.toThrow(/error class.*not a usable identifier/)
await expect(run([namespace('tools', 'console')])).rejects.toThrow(/duplicate injected global/)
await expect(run([namespace('tools', 'tools')])).rejects.toThrow(/duplicate injected global/)
await expect(run([
namespace('tools', 'CallError'),
namespace('helpers', 'CallError'),
])).rejects.toThrow(/duplicate injected global/)
await expect(run([namespace('tools', 'CallError', '')])).rejects.toThrow(/member property.*not usable/)
await expect(run([namespace('tools', 'CallError', 'message')])).rejects.toThrow(/member property.*not usable/)
})
it('rejects config values that are not positive numbers', async () => {
@@ -413,6 +829,12 @@ describe('WorkerCodeRuntime — seam misuse and lifecycle', () => {
await expect(ctx.plugin(WorkerCodeRuntime, { computeMs: -1 })).rejects.toThrow(/positive number/)
})
it('requires maxOutputBytes to fit the smallest counted outer payloads', async () => {
const ctx = new Context()
await expect(ctx.plugin(WorkerCodeRuntime, { maxOutputBytes: 3 })).rejects.toThrow(/safe integer of at least 4/)
await expect(ctx.plugin(WorkerCodeRuntime, { maxOutputBytes: 4.5 })).rejects.toThrow(/safe integer of at least 4/)
})
it('keeps runs isolated: no state survives from one run to the next', async () => {
const { runtime } = await setup()
await runtime.run({ program: 'globalThis.leak = "value"; return 1', bindings: [] })

View File

@@ -0,0 +1,39 @@
import { copyFile, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Worker } from 'node:worker_threads'
import { expect, it } from 'vitest'
import { decodeWorkerJson } from '../src/worker-json.ts'
/**
* Prove the unbuilt worker is a self-contained source closure. Copying it out
* of the workspace makes any package runtime import fail even when local
* `lib/` artifacts happen to exist.
*/
it('boots the source worker without workspace package outputs', async () => {
const directory = await mkdtemp(join(tmpdir(), 'dsh-code-source-worker-'))
let worker: Worker | undefined
try {
const files = ['worker.ts', 'bootstrap.ts', 'protocol.ts', 'worker-json.ts', 'output-json.ts']
await Promise.all(files.map(async (file) => {
await copyFile(new URL(`../src/${file}`, import.meta.url), join(directory, file))
}))
worker = new Worker(join(directory, 'worker.ts'), {
workerData: { code: 'return { answer: 42 }', namespaces: [], maxOutputBytes: 65_536 },
env: {},
execArgv: [],
})
const message = await new Promise<unknown>((resolve, reject) => {
worker?.once('message', resolve)
worker?.once('error', reject)
})
expect(message).toMatchObject({ type: 'done' })
const value = typeof message === 'object' && message !== null ? (message as { value?: unknown }).value : undefined
expect(decodeWorkerJson(value)).toEqual({ answer: 42 })
} finally {
if (worker) await worker.terminate()
await rm(directory, { recursive: true, force: true })
}
})

View File

@@ -0,0 +1,257 @@
import { runInNewContext } from 'node:vm'
import { describe, expect, it } from 'vitest'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import { decodeWorkerJson, encodeWorkerJson, snapshotCodeJsonValue } from '../src/worker-json.ts'
describe('snapshotCodeJsonValue', () => {
it('matches the canonical scalar boundary', () => {
const unsupported = [undefined, 1n, Symbol('value'), () => 1]
for (const value of [null, false, 'text', 1.25, -0, Number.NaN, Number.POSITIVE_INFINITY, ...unsupported]) {
expect(snapshotCodeJsonValue(value)).toEqual(snapshotJsonValue(value))
}
})
it('detaches dense arrays and plain or null-prototype records', () => {
const shared = { value: 1 }
const nullPrototype = Object.assign(Object.create(null) as Record<string, unknown>, { shared })
const source = { list: [nullPrototype, shared], alias: shared }
const snapshot = snapshotCodeJsonValue(source) as Record<string, unknown>
shared.value = 2
expect(snapshot).toEqual({ list: [{ shared: { value: 1 } }, { value: 1 }], alias: { value: 1 } })
expect(snapshot).not.toBe(source)
expect((snapshot.list as unknown[])[0]).not.toBe(nullPrototype)
expect(snapshot.alias).not.toBe(shared)
})
it('accepts intrinsic plain containers from another JavaScript realm', () => {
const foreign = runInNewContext('({ object: { nested: [1] }, array: [2, { ok: true }] })') as {
object: unknown
array: unknown
}
expect(snapshotCodeJsonValue(foreign.object)).toEqual({ nested: [1] })
expect(snapshotCodeJsonValue(foreign.array)).toEqual([2, { ok: true }])
})
it('reads each accepted slot once and preserves a literal __proto__ key', () => {
let objectReads = 0
let arrayReads = 0
const source = Object.create(null) as Record<string, unknown>
Object.defineProperty(source, '__proto__', {
enumerable: true,
get: () => {
objectReads += 1
return { safe: true }
},
})
const array = new Array<unknown>(1)
Object.defineProperty(array, 0, {
enumerable: true,
get: () => {
arrayReads += 1
return arrayReads === 1 ? source : undefined
},
})
const snapshot = snapshotCodeJsonValue(array) as Record<string, unknown>[]
expect(objectReads).toBe(1)
expect(arrayReads).toBe(1)
expect(Object.getPrototypeOf(snapshot[0])).toBe(Object.prototype)
expect(Object.hasOwn(snapshot[0]!, '__proto__')).toBe(true)
expect(snapshot[0]?.['__proto__']).toEqual({ safe: true })
})
it('accepts deeply nested valid JSON without using the JavaScript call stack', () => {
let value: unknown = 'leaf'
for (let depth = 0; depth < 5_000; depth++) value = [value]
let cursor = snapshotCodeJsonValue(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 arrays, cycles, and invalid children', () => {
class ExoticObject {
readonly value = 1
}
class ExoticArray extends Array<number> {}
const cyclic: Record<string, unknown> = {}
cyclic.self = cyclic
const decorated = [1]
Object.defineProperty(decorated, 'extra', { value: true })
const compensatedSparse = new Array(1)
Object.defineProperty(compensatedSparse, '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 forgedPrototype: unknown[] = []
Object.setPrototypeOf(forgedPrototype, null)
const forgedArray = [1]
Object.setPrototypeOf(forgedArray, forgedPrototype)
const spoofedObjectPrototype = Object.create(null) as Record<string, unknown>
const SpoofedObject = function Object() {}
SpoofedObject.prototype = spoofedObjectPrototype
Object.defineProperty(spoofedObjectPrototype, 'constructor', { value: SpoofedObject })
const spoofedObject = Object.create(spoofedObjectPrototype) as Record<string, unknown>
spoofedObject.value = 1
const revokedPrototype = Object.create(null) as Record<string, unknown>
const RevokedObject = function Object() {}
RevokedObject.prototype = revokedPrototype
const revokedConstructor = Proxy.revocable(RevokedObject, {})
Object.defineProperty(revokedPrototype, 'constructor', { value: revokedConstructor.proxy })
const revokedObject = Object.create(revokedPrototype) as Record<string, unknown>
revokedConstructor.revoke()
const spoofedArrayPrototype: unknown[] = []
Object.setPrototypeOf(spoofedArrayPrototype, Object.prototype)
const SpoofedArray = function Array() {}
SpoofedArray.prototype = spoofedArrayPrototype
Object.defineProperty(spoofedArrayPrototype, 'constructor', { value: SpoofedArray })
const spoofedArray = [1]
Object.setPrototypeOf(spoofedArray, spoofedArrayPrototype)
for (const value of [
new ExoticObject(),
new Map([['value', 1]]),
new ExoticArray(1),
new Array(1),
decorated,
compensatedSparse,
symbolDecorated,
hiddenObject,
symbolObject,
customPrototypeObject,
forgedArray,
spoofedObject,
revokedObject,
spoofedArray,
cyclic,
[undefined],
{ value: undefined },
]) {
const canonical = snapshotJsonValue(value)
expect(canonical).toBeUndefined()
expect(snapshotCodeJsonValue(value)).toEqual(canonical)
}
})
it('rejects an array whose getter mutates the validated length', () => {
const array = [0, 2]
Object.defineProperty(array, 0, {
enumerable: true,
get: () => {
array.length = 1
return 1
},
})
expect(snapshotCodeJsonValue(array)).toBeUndefined()
})
it('propagates a throwing getter and releases its recursion guard', () => {
const failure = new Error('getter failed')
const source = Object.defineProperty({}, 'value', {
enumerable: true,
get: () => { throw failure },
})
expect(() => snapshotCodeJsonValue(source)).toThrow(failure)
expect(snapshotCodeJsonValue({ after: true })).toEqual({ after: true })
})
})
describe('flat worker JSON wire', () => {
it('round-trips every JSON root while preserving object keys and container order', () => {
const withPrototypeKey = Object.create(null) as Record<string, unknown>
withPrototypeKey.__proto__ = { safe: true }
const values = [null, false, true, 1.25, 'text', [], {}, [1, { nested: [2] }], withPrototypeKey]
for (const value of values) {
const snapshot = snapshotCodeJsonValue(value)
expect(snapshot).not.toBeUndefined()
expect(decodeWorkerJson(encodeWorkerJson(snapshot!))).toEqual(snapshot)
}
const decoded = decodeWorkerJson(encodeWorkerJson(snapshotCodeJsonValue(withPrototypeKey)!)) as Record<string, unknown>
expect(Object.hasOwn(decoded, '__proto__')).toBe(true)
expect(decoded.__proto__).toEqual({ safe: true })
})
it('round-trips deep values through a bounded-depth token array', () => {
let value: unknown = 'leaf'
for (let depth = 0; depth < 5_000; depth++) value = [value]
const snapshot = snapshotCodeJsonValue(value)!
const wire = encodeWorkerJson(snapshot)
expect(wire).toHaveLength(5_001)
let cursor = decodeWorkerJson(wire)
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 malformed, incomplete, lossy, sparse, decorated, and throwing wire values', () => {
const sparse = new Array(1)
const compensatedSparse = new Array(1)
Object.defineProperty(compensatedSparse, 'extra', { value: true })
const decorated: unknown[] = [null]
Object.defineProperty(decorated, 'extra', { value: true })
const throwing: unknown[] = []
Object.defineProperty(throwing, 0, { enumerable: true, get: () => { throw new Error('wire getter') } })
const decoratedKeys: unknown[] = ['x']
Object.defineProperty(decoratedKeys, 'extra', { value: true })
const foreignMarker: Record<string, unknown> = { kind: 'array', length: 0 }
Object.setPrototypeOf(foreignMarker, {})
const hiddenMarker = Object.defineProperty({ kind: 'array', length: 0 }, 'hidden', { value: true })
for (const value of [
undefined,
null,
{},
[],
sparse,
compensatedSparse,
decorated,
throwing,
[undefined],
[-0],
[Number.NaN],
[Number.POSITIVE_INFINITY],
[1, 2],
[[]],
[foreignMarker],
[hiddenMarker],
[{ kind: 'unknown' }],
[{ kind: 'array', bogus: 0 }],
[{ kind: 'array' }],
[{ kind: 'array', length: '1' }],
[{ kind: 'array', length: -1 }],
[{ kind: 'array', length: Number.MAX_SAFE_INTEGER + 1 }],
[{ kind: 'array', length: 1 }],
[{ kind: 'array', length: 2 }, { kind: 'array', length: 1 }, null],
[{ kind: 'array', length: 0, extra: true }],
[{ kind: 'object' }],
[{ kind: 'object', keys: 'x' }],
[{ kind: 'object', keys: decoratedKeys }],
[{ kind: 'object', keys: [1] }],
[{ kind: 'object', keys: ['x', 'x'] }, 1, 2],
[{ kind: 'object', keys: ['x'] }],
[{ kind: 'object', keys: [], extra: true }],
]) {
expect(decodeWorkerJson(value)).toBeUndefined()
}
})
it('rejects invalid values passed through a forged static type', () => {
expect(() => encodeWorkerJson([undefined] as never)).toThrow(/sparse JSON array/)
expect(() => encodeWorkerJson({ value: undefined } as never)).toThrow(/undefined JSON object property/)
})
})

View File

@@ -8,6 +8,9 @@
"src"
],
"references": [
{
"path": "../../core/session"
},
{
"path": "../../../vendor/cosmokit"
},

View File

@@ -8,15 +8,15 @@ This package is the interface third of the capability (the bash trio is the temp
| Member | Semantics |
|---|---|
| `run(request)` | Execute one program against the request's bindings. **Resolves with an error FIELD for every program outcome** — parse/transform failure, thrown exception, budget expiry, abort, substrate death (`CodeRunFailure`'s orthogonal `kind` taxonomy); it rejects only for caller misuse of the seam itself (e.g. a run submitted after disposal). The program runs as the body of an async function: top-level `await`/`return` work, and the completion value becomes `result.value` when it survives the serialization boundary. |
| `run(request)` | Execute one program against the request's bindings. **Resolves with an error FIELD for every program outcome** — parse/transform failure, thrown exception, invalid completion, output overflow, budget expiry, abort, or substrate death (`CodeRunFailure`'s orthogonal `kind` taxonomy); it rejects only for caller misuse of the seam itself (e.g. a run submitted after disposal). The program runs as the body of an async function: top-level `await`/`return` work, and a lossless JSON completion becomes `result.value`. |
| `language` | Readonly descriptor: the source language `run` expects (`'typescript'` is the well-known value). Informational, not gating — a consumer that generates language-specific presentation switches on it and fails loud on a language it cannot present. |
| `isolation` | Readonly descriptor: the execution substrate (`'worker-thread'`, `'process'`, `'container'`). A label for deployments and diagnostics, **not a security claim**. |
Semantics every implementation must honor (contract details in the class JSDoc): binding calls bridge to the caller's functions verbatim with structured-cloneable arguments/resolutions; the program is treated as a hostile peer (arbitrary binding names are own properties, malformed traffic never crashes the host); no state survives between runs; disposal terminates in-flight runs AND awaits their exit before completing.
Semantics every implementation must honor (contract details in the class JSDoc): binding calls bridge complete lossless-JSON arguments and resolutions with no seam-level byte cap; the program is treated as a hostile peer (arbitrary binding names are own properties, malformed traffic never crashes the host); no state survives between runs; disposal terminates in-flight runs AND awaits their exit before completing.
## Vocabulary
`CodeRunRequest` (`program`, `bindings`, `signal?`) carries everything the runtime acts on — defaulting (time budgets, output caps) is the implementation's validated config, never a hidden `??` inside `run()`. `bindings` is a list of `CodeBindingNamespace`s (`global` + `functions`), each exposed to the program as one global object of async callables. `CodeRunResult` reports the completion `value?`, ordered capped `logs: string[]`, and the `error?` (`CodeRunFailure`: `kind` + model-feedable `message`). See `src/types.ts` for the full contracts.
`CodeRunRequest` (`program`, `bindings`, `signal?`) carries everything the runtime acts on — defaulting (time budgets and outer-output cap) is the implementation's validated config, never a hidden `??` inside `run()`. `bindings` is a list of `CodeBindingNamespace`s (`global` + `functions` + optional `errorClass`), each exposed to the program as one global object of async callables returning `CodeJsonValue`, the seam-local structural equivalent of canonical `JsonValue` that keeps this interface package independent of sessions. An `errorClass` descriptor names a real program-global constructor and the own property that receives the rejected member name; runtimes remain independent of consumer terms such as `ToolCallError`. `CodeRunResult` reports the lossless JSON completion `value?`, ordered `logs: string[]`, and the `error?` (`CodeRunFailure`: `kind` + model-feedable `message`). See `src/types.ts` for the full contracts.
## Model Experience
@@ -31,3 +31,4 @@ No direct invalidation; the named consumer owns any request-prefix changes.
- **`run()` is one-shot** — `logs` arrive only on the resolved `CodeRunResult`; the seam exposes no streaming-log or progress surface for a live program's output.
- **A persistent REPL-style kernel is recorded future work** — the no-state-between-runs contract stands until a persistent-kernel backend brings its own logging story ([Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)).
- **Only the worker-thread backend ships** — `'process'`/`'container'` are declared well-known `isolation` values with no implementation; a hard security boundary awaits a container backend.
- **Intermediate binding values have no byte cap** — implementations remain subject to structured-clone cost and process memory, while a provider or executor may already have imposed its own acquisition bound.

View File

@@ -8,8 +8,10 @@ import { Context, Service } from 'cordis'
import type { CodeRunRequest, CodeRunResult } from './types.ts'
export type {
CodeBindingErrorClass,
CodeBindingFunction,
CodeBindingNamespace,
CodeJsonValue,
CodeRunFailure,
CodeRunRequest,
CodeRunResult,
@@ -24,8 +26,9 @@ declare module 'cordis' {
/**
* Registers one `ctx.codeRuntime` implementation. Program, budget, abort, and substrate
* failures resolve in {@link CodeRunResult}; only seam misuse rejects. Implementations bridge
* structured-cloneable bindings while treating programs as hostile peers, isolate runs from
* one another, and terminate and await in-flight runs during disposal.
* structured-cloneable bindings, materialize each declared namespace rejection
* class, treat programs as hostile peers, isolate runs from one another, and
* terminate and await in-flight runs during disposal.
*/
export abstract class CodeRuntime extends Service {
/**

View File

@@ -9,12 +9,30 @@
/**
* One host-side function exposed to the program as an async callable. The
* runtime bridges calls to it (possibly across a serialization boundary), so
* `args` and the resolution value MUST be structured-cloneable; a runtime
* rejects a non-cloneable value with a descriptive error rather than
* corrupting the run. A rejection of this function surfaces inside the
* program as a rejection of the corresponding call.
* `args` and the resolution value MUST be lossless JSON. A runtime rejects a
* lossy or non-cloneable value with a descriptive error rather than corrupting
* the run. No seam-level byte cap applies to a binding resolution. A rejection
* of this function surfaces inside the program as a rejection of the
* corresponding call.
*/
export type CodeBindingFunction = (args: unknown) => Promise<unknown>
export type CodeBindingFunction = (args: unknown) => Promise<CodeJsonValue>
/** A lossless JSON value transferable across the dependency-light code-runtime seam. */
export type CodeJsonValue = null | boolean | number | string | CodeJsonValue[] | { [key: string]: CodeJsonValue }
/**
* Program-visible typed rejection for one binding namespace. The runtime
* injects a real error constructor under `name`; rejected member calls become
* its instances and expose the exact member name through
* `memberNameProperty`. Both strings are runtime data rather than knowledge
* of a particular consumer such as Code Mode.
*/
export interface CodeBindingErrorClass {
/** Constructor global and resulting `Error.name` (must be a usable JS identifier). */
name: string
/** Non-empty own property for the member name; cannot replace `name`, `message`, or `stack`. */
memberNameProperty: string
}
/**
* A named group of {@link CodeBindingFunction}s the runtime exposes to the
@@ -28,6 +46,8 @@ export interface CodeBindingNamespace {
global: string
/** The callable members, keyed by the exact name the program calls. */
functions: Record<string, CodeBindingFunction>
/** Optional program-visible typed rejection contract for this namespace. */
errorClass?: CodeBindingErrorClass
}
/**
@@ -63,10 +83,12 @@ export interface CodeRunRequest {
* - `'timeout'` — an implementation-owned budget expired; the message says which.
* - `'abort'` — {@link CodeRunRequest.signal} fired.
* - `'worker-exit'` — the execution substrate died without settling (e.g. OOM).
* - `'invalid-output'` — the completion value was not lossless JSON.
* - `'output-limit'` — the serialized outer logs/value/diagnostic exceeded the configured cap.
*/
export interface CodeRunFailure {
/** The failure class (see the interface doc for each kind's meaning). */
kind: 'exception' | 'timeout' | 'abort' | 'worker-exit'
kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' | 'invalid-output' | 'output-limit'
/** Human-readable detail, suitable for feeding back to a model to self-correct. */
message: string
}
@@ -79,12 +101,12 @@ export interface CodeRunFailure {
export interface CodeRunResult {
/**
* The program's completion value (its top-level `return`), when it ran to
* completion and the value survived the runtime's serialization boundary;
* a non-transferable value is replaced by a string rendering, and a failed
* or value-less run leaves this absent.
* completion and the value crossed the runtime's lossless-JSON boundary.
* Invalid or over-limit completions fail the run instead of substituting a
* rendered string; a failed or value-less run leaves this absent.
*/
value?: unknown
/** Text the program emitted, in order (capped by the implementation). */
value?: CodeJsonValue
/** Text the program emitted, in order, bounded only as part of the outer result. */
logs: string[]
/** Present iff the run failed; see {@link CodeRunFailure} for the taxonomy. */
error?: CodeRunFailure

View File

@@ -45,7 +45,7 @@ describe('CodeRuntime service seam', () => {
const calls: unknown[] = []
const result = await runtime.run({
program: 'return 1',
bindings: [{ global: 'tools', functions: { probe: async args => void calls.push(args) } }],
bindings: [{ global: 'tools', functions: { probe: async (args) => { calls.push(args); return null } } }],
})
expect(result).toEqual({ logs: [] })
expect(calls).toEqual([{ from: 'stub' }])

View File

@@ -4,7 +4,7 @@ import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-a
import { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
@@ -134,7 +134,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(TokenMeterService)
ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps))
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'work',
description: 'does work',
parameters: { i: { type: 'number' } },

View File

@@ -5,7 +5,7 @@ import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import * as timeContext from '@deepseek-ai/dsh-time-context'
@@ -386,7 +386,7 @@ describe('real agent-loop request history', () => {
it('persists one ordered context per request, accumulates readings, and leaves system headers unchanged', async () => {
const adapter = new ScriptedAdapter([toolCallResponse(), textResponse('done')])
const ctx = await loopHarness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'tick',
description: 'advance fake time',
parameters: {},

View File

@@ -129,8 +129,7 @@ export function apply(ctx: Context, config: Config): void {
if (update === undefined) return downstream
pendingVersionUpdates.set(exec.token, update.versionUpdates)
return {
kind: 'accept',
...downstream.content !== undefined ? { content: downstream.content } : {},
...downstream,
additionalContexts: [update.context, ...downstream.additionalContexts ?? []],
}
})

View File

@@ -22,8 +22,11 @@ import type {
} from '@deepseek-ai/dsh-fs'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolExecution, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import type {
ToolExecution,
ToolExecutionToken,
} from '@deepseek-ai/dsh-tools'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import {
discoverBaselineInstructionFiles,
@@ -864,6 +867,7 @@ describe('workspace context request injection', () => {
agent: stubAgent('/virtual/repo'),
}), {
isError: false,
value: null,
content: [{ type: 'text', text: 'file content' }],
}, async () => ({
kind: 'accept',
@@ -900,7 +904,8 @@ describe('workspace context request injection', () => {
agent,
})
const result = {
isError: false,
isError: false as const,
value: null,
content: [{ type: 'text' as const, text: 'hello' }],
}
@@ -1699,7 +1704,7 @@ describe('dynamic nested workspace context injection', () => {
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(SessionId('workspace-context-abort'), { provider: 'mock', model: 'mock' }, { cwd: root })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'abort_step',
description: 'Abort the current test step.',
parameters: {},
@@ -1770,6 +1775,7 @@ describe('dynamic nested workspace context injection', () => {
const pending = ctx.waterfall('tools/post-execute', exec, {
content: [{ type: 'text', text: 'ok' }],
isError: false,
value: null,
}, () => Promise.resolve({ kind: 'accept' as const }))
await expect(pending).rejects.toBe(reason)
@@ -2777,7 +2783,8 @@ describe('dynamic nested workspace context injection', () => {
const result = {
callId: CallId('provider-probe-result'),
content: [{ type: 'text' as const, text: 'ok' }],
isError: false,
isError: false as const,
value: null,
}
const failedStat = await ctx.waterfall('tools/post-execute', stubToolExecution({
@@ -2837,7 +2844,7 @@ describe('dynamic nested workspace context injection', () => {
}
})
it('preserves nested and downstream post-execute contexts as separate entries', async () => {
it('preserves a downstream canonical value replacement and keeps contexts separate', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
@@ -2848,7 +2855,12 @@ describe('dynamic nested workspace context injection', () => {
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
ctx.on('tools/post-execute', async () => ({
kind: 'accept' as const,
content: [{ type: 'text' as const, text: 'downstream replacement' }],
value: {
path: 'pkg/deep/file.txt',
offset: 1,
lines: [{ number: 1, text: 'downstream replacement' }],
totalLines: 1,
},
additionalContexts: [{
content: [{ type: 'text' as const, text: 'downstream context' }],
source: { kind: 'plugin' as const, plugin: 'downstream' },
@@ -2863,7 +2875,15 @@ describe('dynamic nested workspace context injection', () => {
agent: stubAgent(root),
})
expect(blocksText(result.content)).toBe('downstream replacement')
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected read replacement success')
expect(result.value).toEqual({
path: 'pkg/deep/file.txt',
offset: 1,
lines: [{ number: 1, text: 'downstream replacement' }],
totalLines: 1,
})
expect(blocksText(result.content)).toContain('downstream replacement')
expect(result.additionalContexts).toHaveLength(2)
expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
expect(workspaceContextOf(result)?.meta).toMatchObject({
@@ -2980,7 +3000,7 @@ describe('dynamic nested workspace context injection', () => {
await ctx.plugin(ToolRegistry)
await ctx.plugin(LocalFileSystem, { cwd: '/' })
await ctx.plugin(ToolFs)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'composite-read',
description: 'read through a nested dispatch',
parameters: {},
@@ -3035,7 +3055,7 @@ describe('dynamic nested workspace context injection', () => {
await ctx.plugin(workspaceContext, { maxBytes: 65536 })
const agent = stubAgent('/')
const parent = Symbol('parent') as ToolExecutionToken
const plainResult = { callId: CallId('plain'), content: [], isError: false }
const plainResult = { callId: CallId('plain'), content: [], isError: false as const, value: null }
ctx.emit('tools/result', stubToolExecution({
signal: testToolSignal,
@@ -3077,7 +3097,8 @@ describe('dynamic nested workspace context injection', () => {
const result = {
callId: CallId('manual'),
content: [{ type: 'text' as const, text: 'manual result' }],
isError: false,
isError: false as const,
value: null,
}
const cases = [
{ name: 'read', arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent: undefined },

View File

@@ -10,9 +10,11 @@ The self-referential cordis toolset: three model-facing tools over the live runt
Exact model-facing schemas: [the generated tool catalog](../../../docs/tool-catalog.md).
Canonical successes are the inspection string, mount `{ id, pluginName, state, provides, waitingFor }`, and unmount `{ id, pluginName }`. Native renderers preserve the existing prose, so programs can use `mounted.id` while ordinary function calling still sees `mounted dyn-1 (...)`.
## Trust stance
The sandbox isolates globals but is not a security boundary. Node globals are absent or redirect to Cordis services such as `ctx.fs`, `ctx.web`, and `ctx.bash`, and writes to `globalThis` stay local, but host-realm helpers make escape possible. Mounted plugins receive a façade without framework internals, yet its allowed services affect the live runtime. Treat this toolset like bash access; see the [design and trust stance](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
The sandbox isolates globals but is not a security boundary. Node globals are absent or redirect to Cordis services such as `ctx.fs`, `ctx.web`, and `ctx.bash`, and writes to `globalThis` stay local, but host-realm helpers make escape possible. Mounted plugins receive a façade without framework internals, yet its allowed services affect the live runtime. Dynamic tool schemas and annotations cross the realm through iterative JSON cloning and schema normalization, so valid deep declarations are memory-bounded rather than call-stack-bounded; records with JSON-invisible keys and subclassed or decorated schema arrays reject before normalization. Treat this toolset like bash access; see the [design and trust stance](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
## Config

View File

@@ -1253,17 +1253,25 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'CallId',
declaration: 'export type CallId = Branded<\'CallId\'>;',
},
{
name: 'CodeBindingErrorClass',
declaration: 'export interface CodeBindingErrorClass {\n name: string;\n memberNameProperty: string;\n}',
},
{
name: 'CodeBindingFunction',
declaration: 'export type CodeBindingFunction = (args: unknown) => Promise<unknown>;',
declaration: 'export type CodeBindingFunction = (args: unknown) => Promise<CodeJsonValue>;',
},
{
name: 'CodeBindingNamespace',
declaration: 'export interface CodeBindingNamespace {\n global: string;\n functions: Record<string, CodeBindingFunction>;\n}',
declaration: 'export interface CodeBindingNamespace {\n global: string;\n functions: Record<string, CodeBindingFunction>;\n errorClass?: CodeBindingErrorClass;\n}',
},
{
name: 'CodeJsonValue',
declaration: 'export type CodeJsonValue = null | boolean | number | string | CodeJsonValue[] | {\n [key: string]: CodeJsonValue;\n};',
},
{
name: 'CodeRunFailure',
declaration: 'export interface CodeRunFailure {\n kind: \'exception\' | \'timeout\' | \'abort\' | \'worker-exit\';\n message: string;\n}',
declaration: 'export interface CodeRunFailure {\n kind: \'exception\' | \'timeout\' | \'abort\' | \'worker-exit\' | \'invalid-output\' | \'output-limit\';\n message: string;\n}',
},
{
name: 'CodeRunRequest',
@@ -1271,7 +1279,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'CodeRunResult',
declaration: 'export interface CodeRunResult {\n value?: unknown;\n logs: string[];\n error?: CodeRunFailure;\n}',
declaration: 'export interface CodeRunResult {\n value?: CodeJsonValue;\n logs: string[];\n error?: CodeRunFailure;\n}',
},
{
name: 'CollectedOutput',
@@ -1473,6 +1481,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'InvariantInstaller',
declaration: 'export interface InvariantInstaller {\n (ctx: Context, fail: InvariantFailure): void | Promise<void>;\n readonly inject?: Inject;\n}',
},
{
name: 'JsonSchemaNode',
declaration: 'export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record<string, JsonSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n}',
},
{
name: 'JsonSchemaScalar',
declaration: 'export type JsonSchemaScalar = string | number | boolean | null;',
},
{
name: 'JsonSchemaType',
declaration: 'export type JsonSchemaType = \'object\' | \'array\' | \'string\' | \'number\' | \'integer\' | \'boolean\' | \'null\';',
},
{
name: 'JsonValue',
declaration: 'export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n};',
@@ -1509,6 +1529,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'MessageSourceMap',
declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n plugin: {\n kind: \'plugin\';\n plugin: string;\n };\n}',
},
{
name: 'ObjectJsonSchema',
declaration: 'export type ObjectJsonSchema = JsonSchemaNode & {\n type: \'object\';\n};',
},
{
name: 'OutOfBandSessionEventMap',
declaration: 'export interface OutOfBandSessionEventMap {\n}',
@@ -1679,7 +1703,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionEventMap',
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': PromptMessageData;\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n };\n \'steering/message\': PromptMessageData & {\n turn: number;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: Req /* …truncated — full shape in source */',
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': PromptMessageData;\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'steering/message\': PromptMessageData & {\n turn: number;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: R /* …truncated — full shape in source */',
},
{
name: 'SessionEventReadRequest',
@@ -1841,22 +1865,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'StreamChunk',
declaration: 'export type StreamChunk = {\n type: \'block-start\';\n index: number;\n blockType: ContentBlockType;\n} | {\n type: \'text-delta\';\n index: number;\n text: string;\n} | {\n type: \'reasoning-delta\';\n index: number;\n text: string;\n} | {\n type: \'tool-call-delta\';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n} | {\n type: \'block-end\';\n index: number;\n block: ContentBlock;\n} | {\n type: \'usage\';\n usage: TokenUsage;\n} | {\n type: \'finish\';\n reason: FinishReason;\n replayState?: unknown;\n};',
},
{
name: 'StructuredOutputSchema',
declaration: 'export type StructuredOutputSchema = StructuredSchemaNode & {\n type: \'object\';\n};',
},
{
name: 'StructuredScalar',
declaration: 'export type StructuredScalar = string | number | boolean | null;',
},
{
name: 'StructuredSchemaNode',
declaration: 'export interface StructuredSchemaNode {\n type: StructuredSchemaType;\n properties?: Record<string, StructuredSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: StructuredSchemaNode;\n enum?: StructuredScalar[];\n const?: StructuredScalar;\n description?: string;\n title?: string;\n default?: unknown;\n examples?: unknown;\n}',
},
{
name: 'StructuredSchemaType',
declaration: 'export type StructuredSchemaType = \'object\' | \'array\' | \'string\' | \'number\' | \'integer\' | \'boolean\' | \'null\';',
},
{
name: 'SubagentCapabilities',
declaration: 'export interface SubagentCapabilities {\n readonly outputSchema: boolean;\n readonly depthLimit: boolean;\n readonly toolFilter: boolean;\n readonly persona: boolean;\n}',
@@ -1875,7 +1883,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SubagentStartRequest',
declaration: 'export interface SubagentStartRequest {\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly agentOptions?: AgentOptions;\n readonly outputSchema?: StructuredOutputSchema;\n readonly maxDepth?: number;\n readonly toolFilter?: ToolRestriction;\n readonly persona?: string;\n}',
declaration: 'export interface SubagentStartRequest {\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly agentOptions?: AgentOptions;\n readonly outputSchema?: ObjectJsonSchema;\n readonly maxDepth?: number;\n readonly toolFilter?: ToolRestriction;\n readonly persona?: string;\n}',
},
{
name: 'SubagentStopReason',
@@ -1979,20 +1987,20 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ToolDefinition',
declaration: 'export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn>;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n}',
declaration: 'export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n}',
},
{
name: 'ToolErrorInfo',
declaration: 'export interface ToolErrorInfo {\n name: string;\n code: string;\n}',
},
{
name: 'ToolExecuteReturn',
declaration: 'export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n};',
},
{
name: 'ToolExecution',
declaration: 'export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n}',
},
{
name: 'ToolExecutionFailure',
declaration: 'export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n}',
},
{
name: 'ToolExecutionInput',
declaration: 'export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n}',
@@ -2003,16 +2011,28 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ToolExecutionResult',
declaration: 'export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n}',
declaration: 'export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;',
},
{
name: 'ToolExecutionSuccess',
declaration: 'export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n}',
},
{
name: 'ToolExecutionToken',
declaration: 'export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n};',
},
{
name: 'ToolFailure',
declaration: 'export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n}',
},
{
name: 'ToolGuard',
declaration: 'export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;',
},
{
name: 'ToolOutputDefinition',
declaration: 'export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n}',
},
{
name: 'ToolProviderResult',
declaration: 'export interface ToolProviderResult {\n readonly schemas: readonly ToolSchema[];\n readonly knownNames?: readonly string[];\n}',
@@ -2023,7 +2043,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ToolResult',
declaration: 'export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n}',
declaration: 'export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n}',
},
{
name: 'ToolResultBlock',

View File

@@ -21,11 +21,11 @@ export const FiberState = {
export type FiberState = FiberStateEnum
/** Human-readable label for each {@link FiberState}, keyed by member (inlining-safe — no reverse mapping). */
export const STATE_LABELS: Record<FiberState, string> = {
export const STATE_LABELS = {
[FiberState.PENDING]: 'pending',
[FiberState.LOADING]: 'loading',
[FiberState.ACTIVE]: 'active',
[FiberState.FAILED]: 'failed',
[FiberState.DISPOSED]: 'disposed',
[FiberState.UNLOADING]: 'unloading',
}
} as const satisfies Record<FiberState, string>

View File

@@ -1,13 +1,13 @@
/**
* The registration boundary between sandboxed mount code and the real runtime: SchemaSpec
* The registration boundary between sandboxed mount code and the real runtime: ParameterSchemaSpec
* normalization + validation with teaching errors, the marker-guarded `harness.defineTool` /
* `harness.registerTool` pair, the SANDBOX CONTEXT FAÇADE a mounted plugin's `apply` receives
* in place of the real `ctx`, and the plugin-shape helpers the mount lifecycle narrows sandbox
* return values with. The façade is a whitelist of lifecycle-safe verbs and declared services;
* framework internals and context-valued service returns are denied.
*
* VM-realm schemas are rebuilt as host objects, and tool results are JSON-round-tripped and
* shape-checked before session logging. Common JSON-Schema spellings are normalized when they
* VM-realm schemas and canonical values are rebuilt as host objects, while rendered content and
* presentation metadata are shape-checked before entering the registry. Common JSON-Schema spellings are normalized when they
* have one meaning; invalid vocabulary fails during registration with a teaching error.
* @module @deepseek-ai/dsh-tool-cordis/guard
*/
@@ -15,84 +15,470 @@
import { Context } from 'cordis'
import type { Plugin } from 'cordis'
import { scopeOf } from '@deepseek-ai/dsh-scope'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolDefinition, ToolExecuteReturn } from '@deepseek-ai/dsh-tools'
import { assertSupportedJsonSchema, defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { JsonValue } from '@deepseek-ai/dsh-session'
const DYNAMIC_TOOL = Symbol('tool-cordis.dynamic-tool')
const SCHEMA_TYPES = new Set<unknown>(['string', 'number', 'boolean', 'object', 'array'])
const VALID_TYPES = '\'string\' | \'number\' | \'boolean\' | \'object\' | \'array\''
const SCHEMA_TYPES = new Set<unknown>(['string', 'number', 'integer', 'boolean', 'null', 'object', 'array', 'json'])
const VALID_TYPES = '\'string\' | \'number\' | \'integer\' | \'boolean\' | \'null\' | \'object\' | \'array\' | \'json\''
const ANNOTATION_KEYS = ['description', 'title', 'default', 'examples'] as const
type DynamicToolDefinition = ToolDefinition & { [DYNAMIC_TOOL]: true }
type DynamicToolMarker = { [DYNAMIC_TOOL]?: unknown }
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Object.prototype.toString.call(value) === '[object Object]'
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
const prototype: unknown = Object.getPrototypeOf(value)
return prototype === null
|| typeof prototype === 'object'
&& Object.getPrototypeOf(prototype) === null
&& hasIntrinsicConstructor(prototype, 'Object')
}
/* jscpd:ignore-start -- this VM boundary mirrors the session-owned realm-safe 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 {
return constructor.name === name
&& constructor.prototype === prototype
&& Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }`
} catch {
return false
}
}
/** Whether an array uses one realm's intrinsic Array prototype rather than a subclass. */
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
&& Object.getPrototypeOf(objectPrototype) === null
&& hasIntrinsicConstructor(objectPrototype, 'Object')
}
/* jscpd:ignore-end */
/** Whether a schema list is a dense intrinsic array with no JSON-invisible decorations. */
function isDensePlainArray(value: unknown): value is unknown[] {
if (!Array.isArray(value) || !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
}
/** Reject schema records whose declarations would disappear from object enumeration. */
function assertSchemaContainerKeys(value: Record<string, unknown>, path: string): void {
if (Reflect.ownKeys(value).some(key => typeof key !== 'string' || !Object.prototype.propertyIsEnumerable.call(value, key))) {
throw new Error(`harness.defineTool ${path} must contain only own enumerable string keys`)
}
}
/** Where one cloned JSON value is installed. */
type CloneDestination =
| { kind: 'root' }
| { kind: 'array'; target: unknown[]; index: number }
| { kind: 'object'; target: Record<string, unknown>; key: string }
/** Deferred work for stack-safe cross-realm JSON cloning. */
type CloneTask =
| { kind: 'visit'; value: unknown; path: string; destination: CloneDestination }
| { kind: 'array-item'; source: unknown[]; index: number; path: string; target: unknown[] }
| { kind: 'leave'; source: object }
/** Materialize realm-foreign lossless JSON without allowing JSON.stringify coercions. */
function cloneJson(value: unknown, path: string): unknown {
const ancestors = new Set<object>()
let root: unknown
const assign = (destination: CloneDestination, item: unknown): void => {
if (destination.kind === 'root') {
root = item
return
}
if (destination.kind === 'array') {
destination.target[destination.index] = item
return
}
Object.defineProperty(destination.target, destination.key, {
value: item,
enumerable: true,
configurable: true,
writable: true,
})
}
const reject = (at: string): never => {
throw new Error(`harness.defineTool ${at} must be lossless JSON data`)
}
const tasks: CloneTask[] = [{ kind: 'visit', value, path, destination: { kind: 'root' } }]
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.hasOwn(task.source, task.index)) reject(task.path)
tasks.push({
kind: 'visit',
value: task.source[task.index],
path: `${task.path}[${task.index}]`,
destination: { kind: 'array', target: task.target, index: task.index },
})
continue
}
const current = task.value
if (current === null || typeof current === 'string' || typeof current === 'boolean') {
assign(task.destination, current)
continue
}
if (typeof current === 'number') {
if (!Number.isFinite(current) || Object.is(current, -0)) reject(task.path)
assign(task.destination, current)
continue
}
if (typeof current !== 'object' || ancestors.has(current)) reject(task.path)
if (Array.isArray(current)) {
if (!hasPlainArrayPrototype(current) || Reflect.ownKeys(current).length !== current.length + 1) reject(task.path)
const output: unknown[] = []
assign(task.destination, output)
ancestors.add(current)
tasks.push({ kind: 'leave', source: current })
for (let index = current.length - 1; index >= 0; index--) {
tasks.push({ kind: 'array-item', source: current, index, path: task.path, target: output })
}
continue
}
if (!isPlainRecord(current)) reject(task.path)
const record = current as Record<string, unknown>
if (Reflect.ownKeys(record).some(key => typeof key !== 'string' || !Object.prototype.propertyIsEnumerable.call(record, key))) {
reject(task.path)
}
const output: Record<string, unknown> = {}
assign(task.destination, output)
ancestors.add(record)
tasks.push({ kind: 'leave', source: record })
const entries = Object.entries(record)
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: 'visit',
value: entry[1],
path: `${task.path}.${entry[0]}`,
destination: { kind: 'object', target: output, key: entry[0] },
})
}
}
return root
}
/** Copy and realm-materialize the shared annotation vocabulary. */
function copyAnnotations(value: Record<string, unknown>, output: Record<string, unknown>, path: string): void {
if (Object.hasOwn(value, 'description')) output.description = value.description
if (Object.hasOwn(value, 'title')) output.title = value.title
if (Object.hasOwn(value, 'default')) output.default = cloneJson(value.default, `${path}.default`)
if (Object.hasOwn(value, 'examples')) output.examples = cloneJson(value.examples, `${path}.examples`)
}
/** Reject sandbox schema keys that the unified DSL would otherwise ignore. */
function assertSchemaKeys(value: Record<string, unknown>, path: string, allowed: readonly string[]): void {
assertSchemaContainerKeys(value, path)
for (const key of Object.keys(value)) {
if (!allowed.includes(key)) throw new Error(`harness.defineTool ${path}.${key} is not supported by the unified schema DSL`)
}
}
/**
* Normalize a sandbox-provided `parameters` value into a fresh host-realm
* SchemaSpec. Accepts the DSL directly, or the JSON-Schema-style
* `{ type: 'object', properties, required: […] }` wrapper models write by
* prior — the wrapper unwraps and its `required` array becomes per-property
* flags (see the module doc).
* ParameterSchemaSpec. A raw JSON-Schema object wrapper retains its open root
* default, while the direct DSL is already an implicit open property map.
*/
function normalizeSchemaSpec(value: unknown, path = 'parameters'): Record<string, unknown> {
function normalizeParameterSchemaSpec(value: unknown, path = 'parameters'): {
spec: Record<string, unknown>
rootAnnotations?: Record<string, unknown>
} {
if (!isPlainRecord(value)) {
throw new Error(`harness.defineTool ${path} must be a SchemaSpec object`)
throw new Error(`harness.defineTool ${path} must be a ParameterSchemaSpec object`)
}
let entries = value
const requiredNames = new Set<unknown>()
if (value.type === 'object' && isPlainRecord(value.properties)) {
if (Array.isArray(value.required)) {
for (const name of value.required) requiredNames.add(name)
if (value.type === 'object') {
assertSchemaKeys(value, path, ['type', 'properties', 'required', 'additionalProperties', ...ANNOTATION_KEYS])
if (!isPlainRecord(value.properties)) {
throw new Error(`harness.defineTool ${path}.properties must be an object of schemas`)
}
if (Object.hasOwn(value, 'additionalProperties') && value.additionalProperties !== true) {
throw new Error(`harness.defineTool ${path}.additionalProperties must be true or omitted because the implicit parameter root is open`)
}
if (Object.hasOwn(value, 'required') && value.required === undefined) {
throw new Error(`harness.defineTool ${path}.required must be an array of declared property names`)
}
const required = normalizeRequiredNames(value.required, value.properties, `${path}.required`)
const rootAnnotations: Record<string, unknown> = {}
copyAnnotations(value, rootAnnotations, path)
return {
spec: normalizePropertyMap(value.properties, path, required, true),
...(Object.keys(rootAnnotations).length === 0 ? {} : { rootAnnotations }),
}
entries = value.properties
}
const spec: Record<string, unknown> = {}
for (const [key, prop] of Object.entries(entries)) {
spec[key] = normalizeSchemaProp(prop, `${path}.${key}`, requiredNames.has(key))
}
return spec
return { spec: normalizePropertyMap(value, path, new Set(), false) }
}
/** Normalize one property: `integer` → `number`, `required: false` → absent, nested wrappers unwrapped recursively. */
function normalizeSchemaProp(value: unknown, path: string, forceRequired = false): Record<string, unknown> {
if (!isPlainRecord(value)) {
throw new Error(`harness.defineTool ${path} must be a SchemaSpec property object`)
/** Validate raw required names and return their lookup set. */
function normalizeRequiredNames(value: unknown, properties: Record<string, unknown>, path: string): Set<string> {
if (value === undefined) return new Set()
if (!isDensePlainArray(value)) {
throw new Error(`harness.defineTool ${path} must be an array of declared property names`)
}
const type = value.type === 'integer' ? 'number' : value.type
if (!SCHEMA_TYPES.has(type)) {
throw new Error(`harness.defineTool ${path} must declare a valid type: ${VALID_TYPES} (got ${JSON.stringify(value.type)})`)
}
// On an object property a JSON-Schema-style `required` ARRAY names required
// children (handled by the nested unwrap below); everywhere else `required`
// must be a boolean, and `false` means optional.
const nestedRequiredArray = type === 'object' && Array.isArray(value.required)
if (value.required !== undefined && typeof value.required !== 'boolean' && !nestedRequiredArray) {
throw new Error(`harness.defineTool ${path}.required must be a boolean when present`)
}
const prop: Record<string, unknown> = { type }
if (forceRequired || value.required === true) prop.required = true
if (typeof value.description === 'string') prop.description = value.description
if (Array.isArray(value.enum)) prop.enum = [...value.enum as unknown[]]
if (value.default !== undefined) prop.default = value.default
if (value.properties !== undefined) {
if (type !== 'object') {
throw new Error(`harness.defineTool ${path}.properties is only valid for type "object"`)
const names = new Set<string>()
for (let index = 0; index < value.length; index++) {
const name = value[index]
if (typeof name !== 'string') {
throw new Error(`harness.defineTool ${path} must be an array of declared property names`)
}
// Re-wrap so the nested unwrap applies a nested `required` array too.
prop.properties = normalizeSchemaSpec(
{ type: 'object', properties: value.properties, required: value.required },
`${path}.properties`,
)
names.add(name)
if (!Object.hasOwn(properties, name)) throw new Error(`harness.defineTool ${path} names undeclared property ${JSON.stringify(name)}`)
}
if (value.items !== undefined) {
if (type !== 'array') {
throw new Error(`harness.defineTool ${path}.items is only valid for type "array"`)
return names
}
/** Mutable holder used only while one normalized property-map root is unresolved. */
interface NormalizeRoot {
value?: Record<string, unknown>
}
/** Where a normalized value node is installed. */
type NormalizeValueDestination =
| { kind: 'property'; target: Record<string, unknown>; key: string }
| { kind: 'item'; target: Record<string, unknown> }
| { kind: 'one-of'; target: Record<string, unknown>[]; index: number }
/** Where a normalized property map is installed. */
type NormalizeMapDestination =
| { kind: 'root'; holder: NormalizeRoot }
| { kind: 'properties'; target: Record<string, unknown> }
/** Deferred work for stack-safe sandbox schema normalization. */
type NormalizeTask =
| {
kind: 'map'
entries: Record<string, unknown>
path: string
requiredNames: ReadonlySet<string>
raw: boolean
destination: NormalizeMapDestination
}
| {
kind: 'value'
value: unknown
path: string
forceRequired: boolean
raw: boolean
parameterProperty: boolean
destination: NormalizeValueDestination
}
| { kind: 'leave'; value: object }
/** Install one normalized node without `__proto__` assignment semantics. */
function assignNormalizedValue(destination: NormalizeValueDestination, value: Record<string, unknown>): void {
if (destination.kind === 'property') {
Object.defineProperty(destination.target, destination.key, {
value,
enumerable: true,
configurable: true,
writable: true,
})
} else if (destination.kind === 'item') {
destination.target.items = value
} else {
destination.target[destination.index] = value
}
}
/** Install one normalized property map at its root or containing object. */
function assignNormalizedMap(destination: NormalizeMapDestination, value: Record<string, unknown>): void {
if (destination.kind === 'root') destination.holder.value = value
else destination.target.properties = value
}
/** Normalize one implicit property map and all descendants with explicit work frames. */
function normalizePropertyMap(
entries: Record<string, unknown>,
path: string,
requiredNames: ReadonlySet<string>,
raw: boolean,
): Record<string, unknown> {
const holder: NormalizeRoot = {}
const ancestors = new Set<object>()
const tasks: NormalizeTask[] = [{
kind: 'map',
entries,
path,
requiredNames,
raw,
destination: { kind: 'root', holder },
}]
for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
if (task.kind === 'leave') {
ancestors.delete(task.value)
continue
}
if (task.kind === 'map') {
if (ancestors.has(task.entries)) throw new Error(`harness.defineTool ${task.path} is circular`)
assertSchemaContainerKeys(task.entries, task.path)
ancestors.add(task.entries)
const spec: Record<string, unknown> = {}
assignNormalizedMap(task.destination, spec)
tasks.push({ kind: 'leave', value: task.entries })
const mapEntries = Object.entries(task.entries)
for (let index = mapEntries.length - 1; index >= 0; index--) {
const entry = mapEntries[index]
/* v8 ignore next -- the loop is bounded by the captured entry count. */
if (entry === undefined) continue
tasks.push({
kind: 'value',
value: entry[1],
path: `${task.path}.${entry[0]}`,
forceRequired: task.requiredNames.has(entry[0]),
raw: task.raw,
parameterProperty: true,
destination: { kind: 'property', target: spec, key: entry[0] },
})
}
continue
}
const { value, path } = task
if (!isPlainRecord(value)) {
throw new Error(`harness.defineTool ${path} must be a ParameterSchemaSpec property object`)
}
assertSchemaContainerKeys(value, path)
if (ancestors.has(value)) throw new Error(`harness.defineTool ${path} is circular`)
ancestors.add(value)
const requiredKey = task.parameterProperty && !task.raw ? ['required'] : []
if (task.parameterProperty && task.raw && Object.hasOwn(value, 'required') && value.type !== 'object') {
throw new Error(`harness.defineTool ${path}.required belongs to the containing raw object schema`)
}
if (task.parameterProperty && !task.raw && Object.hasOwn(value, 'required') && value.required !== true) {
throw new Error(`harness.defineTool ${path}.required must be true when present`)
}
const prop: Record<string, unknown> = {}
assignNormalizedValue(task.destination, prop)
tasks.push({ kind: 'leave', value })
if (task.forceRequired || value.required === true) prop.required = true
copyAnnotations(value, prop, path)
if (Object.hasOwn(value, 'oneOf')) {
assertSchemaKeys(value, path, ['oneOf', ...requiredKey, ...ANNOTATION_KEYS])
if (!isDensePlainArray(value.oneOf) || value.oneOf.length < 2) {
throw new Error(`harness.defineTool ${path}.oneOf must contain at least two schemas`)
}
const oneOf: Record<string, unknown>[] = []
prop.oneOf = oneOf
for (let index = value.oneOf.length - 1; index >= 0; index--) {
tasks.push({
kind: 'value',
value: value.oneOf[index],
path: `${path}.oneOf[${index}]`,
forceRequired: false,
raw: task.raw,
parameterProperty: false,
destination: { kind: 'one-of', target: oneOf, index },
})
}
continue
}
if (task.raw && !Object.hasOwn(value, 'type')) {
assertSchemaKeys(value, path, ANNOTATION_KEYS)
prop.type = 'json'
continue
}
if (!SCHEMA_TYPES.has(value.type) || task.raw && value.type === 'json') {
throw new Error(`harness.defineTool ${path} must declare a valid type: ${VALID_TYPES} (got ${JSON.stringify(value.type)})`)
}
const type = value.type
prop.type = type
switch (type) {
case 'object': {
assertSchemaKeys(value, path, ['type', 'properties', 'additionalProperties', ...requiredKey, ...(task.raw ? ['required'] : []), ...ANNOTATION_KEYS])
if (!task.raw && (!Object.hasOwn(value, 'additionalProperties') || typeof value.additionalProperties !== 'boolean')) {
throw new Error(`harness.defineTool ${path}.additionalProperties must be explicitly true or false`)
}
if (task.raw && Object.hasOwn(value, 'additionalProperties') && typeof value.additionalProperties !== 'boolean') {
throw new Error(`harness.defineTool ${path}.additionalProperties must be a boolean`)
}
if (task.raw && Object.hasOwn(value, 'required') && value.required === undefined) {
throw new Error(`harness.defineTool ${path}.required must be an array of declared property names`)
}
prop.additionalProperties = task.raw ? value.additionalProperties ?? true : value.additionalProperties
if (Object.hasOwn(value, 'properties')) {
const properties = value.properties
if (!isPlainRecord(properties)) throw new Error(`harness.defineTool ${path}.properties must be an object of schemas`)
const nestedRequired = task.raw
? normalizeRequiredNames(value.required, properties, `${path}.required`)
: new Set<string>()
tasks.push({
kind: 'map',
entries: properties,
path: `${path}.properties`,
requiredNames: nestedRequired,
raw: task.raw,
destination: { kind: 'properties', target: prop },
})
} else if (task.raw && value.required !== undefined) {
normalizeRequiredNames(value.required, {}, `${path}.required`)
}
break
}
case 'array':
assertSchemaKeys(value, path, ['type', 'items', ...requiredKey, ...ANNOTATION_KEYS])
if (Object.hasOwn(value, 'items')) {
tasks.push({
kind: 'value',
value: value.items,
path: `${path}.items`,
forceRequired: false,
raw: task.raw,
parameterProperty: false,
destination: { kind: 'item', target: prop },
})
}
break
case 'string':
case 'number':
case 'integer':
case 'boolean':
case 'null':
assertSchemaKeys(value, path, ['type', 'enum', 'const', ...requiredKey, ...ANNOTATION_KEYS])
if (Object.hasOwn(value, 'enum')) {
if (!isDensePlainArray(value.enum) || value.enum.length === 0) {
throw new Error(`harness.defineTool ${path}.enum must be a non-empty array`)
}
prop.enum = cloneJson(value.enum, `${path}.enum`)
}
if (Object.hasOwn(value, 'const')) prop.const = cloneJson(value.const, `${path}.const`)
break
case 'json':
assertSchemaKeys(value, path, ['type', ...requiredKey, ...ANNOTATION_KEYS])
break
/* v8 ignore next 2 -- SCHEMA_TYPES narrows this closed switch before dispatch. */
default:
throw new Error(`harness.defineTool ${path} must declare a valid type: ${VALID_TYPES}`)
}
prop.items = normalizeSchemaProp(value.items, `${path}.items`)
}
return prop
/* v8 ignore next -- the root map task assigns before scheduling descendants. */
return holder.value ?? {}
}
function markDynamicTool(tool: ToolDefinition): DynamicToolDefinition {
@@ -127,60 +513,75 @@ const RETURN_PREVIEW_LIMIT = 120
* (`String(…)` for the un-stringifiable undefined case), truncated to
* {@link RETURN_PREVIEW_LIMIT}.
*/
function describeReturn(value: unknown): string {
// JSON.stringify is TYPED as always returning string, but it yields
// undefined for an undefined input (the routed forgot-return case) — the
// assertion widens the type back to the runtime truth.
const json = JSON.stringify(value) as string | undefined
if (json === undefined) return String(value)
function describeReturn(value: JsonValue): string {
// The caller has already crossed cloneJson, so this value is lossless JSON
// and serialization cannot produce undefined.
const json = JSON.stringify(value)
return json.length > RETURN_PREVIEW_LIMIT ? `${json.slice(0, RETURN_PREVIEW_LIMIT)}` : json
}
/**
* Validate a round-tripped `execute` return against the two shapes
* {@link ToolExecuteReturn} allows: an ARRAY of content blocks, or
* `{ content: blocks, meta? }`. The registry trusts the shape blindly — it
* spreads `result.content`, so an unvalidated `{ content: 'ok' }` would enter
* the session log as `['o','k']` and silently corrupt the next model request —
* so a wrong shape fails THIS call with a teaching error instead.
* Validate and host-materialize a sandbox renderer's content blocks.
*/
function assertExecuteReturn(value: unknown): ToolExecuteReturn {
function assertRenderedContent(value: JsonValue): ContentBlock[] {
if (Array.isArray(value) && value.every(isContentBlockShape)) {
return value as ToolExecuteReturn
}
if (isPlainRecord(value) && Array.isArray(value.content) && value.content.every(isContentBlockShape)) {
return value as ToolExecuteReturn
return value as unknown as ContentBlock[]
}
throw new Error(
`execute returned ${describeReturn(value)}a tool's execute must return an ARRAY of content blocks, never a bare string:\n`
+ ' ✓ return [{ type: \'text\', text: someString }]\n'
+ ' ✓ return { content: [{ type: \'text\', text: someString }], meta: anyJsonValue }',
`output.render returned ${describeReturn(value)}it must return an ARRAY of content blocks:\n`
+ ' ✓ return [{ type: \'text\', text: String(value) }]',
)
}
/**
* The `harness.defineTool` handed into the sandbox: the real DSL, with `parameters` normalized
* into a fresh host-realm SchemaSpec (JSON-Schema wrapper unwrapped, `integer` mapped,
* `required: false` dropped) and the tool's `execute` return normalized into the host realm
* into a fresh host-realm ParameterSchemaSpec (raw object wrappers unwrapped,
* required arrays mapped, and explicit DSL object openness enforced) and the tool's `execute` return normalized into the host realm
* via a JSON round-trip. Non-JSON or wrong-shape output fails that call instead of poisoning
* the session log.
* @param options - the standard `defineTool` options; `parameters` may be the SchemaSpec DSL or a JSON-Schema-style wrapper.
* @param options - the standard `defineTool` options; `parameters` may be the ParameterSchemaSpec DSL or a JSON-Schema-style wrapper.
* @returns the marker-tagged definition `harness.registerTool` (and the guarded `ctx.tools.register`) accepts.
*/
export function sandboxDefineTool(options: Parameters<typeof defineTool>[0]): ToolDefinition {
const parameters = normalizeSchemaSpec((options as { parameters?: unknown }).parameters)
const tool = defineTool({ ...options, parameters } as Parameters<typeof defineTool>[0])
const execute = tool.execute.bind(tool)
export function sandboxDefineTool(options: unknown): ToolDefinition {
if (!isPlainRecord(options)) throw new Error('harness.defineTool options must be an object')
const normalized = normalizeParameterSchemaSpec(options.parameters)
if (!isPlainRecord(options.output)) {
throw new Error('harness.defineTool output must declare { schema, render, presentationMeta? }')
}
const output = options.output
if (typeof output.render !== 'function') throw new Error('harness.defineTool output.render must be a function')
if (output.presentationMeta !== undefined && typeof output.presentationMeta !== 'function') {
throw new Error('harness.defineTool output.presentationMeta must be a function when present')
}
if (typeof options.execute !== 'function') throw new Error('harness.defineTool execute must be a function')
const schema = cloneJson(output.schema, 'output.schema')
const rawExecute = options.execute as (args: unknown, exec: unknown) => Promise<unknown>
const rawRender = output.render as (args: unknown, value: unknown) => unknown
const rawPresentationMeta = output.presentationMeta as ((args: unknown, value: unknown) => unknown) | undefined
const erasedDefineTool = defineTool as unknown as (definition: unknown) => ToolDefinition
const tool = erasedDefineTool({
...options,
parameters: normalized.spec,
output: {
schema,
render(args: unknown, value: unknown): ContentBlock[] {
return assertRenderedContent(cloneJson(rawRender(args, value), 'output.render result') as JsonValue)
},
...rawPresentationMeta !== undefined ? {
presentationMeta(args: unknown, value: unknown): JsonValue {
return cloneJson(rawPresentationMeta(args, value), 'output.presentationMeta result') as JsonValue
},
} : {},
},
async execute(args: unknown, exec: unknown): Promise<JsonValue> {
return cloneJson(await rawExecute(args, exec), 'execute result') as JsonValue
},
})
const parameters = { ...tool.parameters, ...normalized.rootAnnotations }
assertSupportedJsonSchema(parameters)
return markDynamicTool({
...tool,
async execute(args, exec) {
// JSON.stringify yields NO JSON for an undefined (or function/symbol)
// return despite its string-typed signature — route that into
// assertExecuteReturn's teaching error rather than letting JSON.parse
// throw its cryptic '"undefined" is not valid JSON'.
const json = JSON.stringify(await execute(args, exec)) as string | undefined
return assertExecuteReturn(json === undefined ? undefined : JSON.parse(json) as unknown)
},
parameters,
})
}

View File

@@ -13,7 +13,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
import { STATE_LABELS } from './fiber-state.ts'
import { isPlugin, pluginName } from './guard.ts'
import { EVENT_API, INHERITED_CTX_API, SERVICE_API, TYPE_API } from './api-catalog.ts'
import { describeApi, describeDynamic, describeEvents, describePlugins, describeServices, describeTools } from './inspect.ts'
import { describeApi, describeDynamic, describeEvents, describePlugins, describeServices, describeTools, providedServices } from './inspect.ts'
import { missingServices, mountDynamic, type DynamicMount } from './mount.ts'
import { presentInspectCall, presentMountCall, presentUnmountCall } from './present.ts'
import { createSandbox, evaluateMountCode } from './sandbox.ts'
@@ -76,7 +76,11 @@ export function apply(ctx: Context, config: Config): void {
description: 'Exact service key or event name whose original JSDoc to include; valid only with what:"api" or what:"events".',
},
},
execute(args, exec): Promise<{ type: 'text'; text: string }[]> {
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
execute(args, exec): Promise<string> {
if (args.name !== undefined && args.what !== 'api' && args.what !== 'events') {
throw new Error('name is valid only with what:"api" or what:"events"')
}
@@ -94,7 +98,7 @@ export function apply(ctx: Context, config: Config): void {
const text = selected
.map(([heading, body]) => `## ${heading}\n${body().join('\n')}`)
.join('\n\n')
return Promise.resolve([{ type: 'text', text }])
return Promise.resolve(text)
},
presentCall: presentInspectCall,
}))
@@ -119,13 +123,14 @@ export function apply(ctx: Context, config: Config): void {
+ 'Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe '
+ 'events (see cordis_inspect what:"events"), or call '
+ '`harness.registerTool(ctx, harness.defineTool({ name, description, parameters: '
+ '{ text: { type: \'string\', required: true } }, async execute(args) { … } }))` '
+ '{ text: { type: \'string\', required: true } }, output: { schema: { type: \'string\' }, '
+ 'render(_args, value) { return [{ type: \'text\', text: value }] } }, async execute(args) { return args.text } }))` '
+ 'to give yourself a new tool — it becomes callable on your NEXT step. '
+ 'Tool parameters: each key IS a property — { type: \'string\'|\'number\'|\'boolean\'|\'object\'|\'array\', '
+ 'required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style '
+ '{ type: \'object\', properties, required: […] } wrapper and type \'integer\' are also accepted and normalized. A '
+ 'tool\'s `execute` MUST return an ARRAY of content blocks, e.g. `return '
+ '[{ type: \'text\', text: someString }]` — never a bare string. '
+ 'Tool parameters: each key IS a property — { type: \'string\'|\'number\'|\'integer\'|\'boolean\'|\'null\'|\'object\'|\'array\'|\'json\', '
+ 'required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and '
+ 'oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: \'object\', properties, required?: […] } wrapper is also accepted with open-by-default objects. A '
+ 'tool\'s `execute` MUST return the lossless JSON value declared by `output.schema`; '
+ '`output.render(args, value)` separately returns Native/model content blocks. '
+ 'Mounts can COMPOSE: one plugin may `ctx.provide(\'name\', value)` a service and '
+ 'another may declare `inject: [\'name\']` to consume it — the consumer stays pending '
+ 'until the provider exists and returns to pending when the provider is unmounted. '
@@ -157,6 +162,32 @@ export function apply(ctx: Context, config: Config): void {
description: 'Body of an async JS function; must `return` the plugin to mount.',
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'string', required: true },
pluginName: { type: 'string', required: true },
state: {
type: 'string',
required: true,
enum: ['pending', 'loading', 'active', 'failed', 'disposed', 'unloading'],
},
provides: { type: 'array', required: true, items: { type: 'string' } },
waitingFor: { type: 'array', required: true, items: { type: 'string' } },
},
},
render: (_args, value) => {
const note = value.waitingFor.length > 0
? ` — waiting for service(s): ${value.waitingFor.join(', ')} (activates when provided)`
: ''
return [{
type: 'text',
text: `mounted ${value.id} (plugin "${value.pluginName}", state: ${value.state}${note})`,
}]
},
},
async execute(args) {
const id = `dyn-${nextId++}`
const sandbox = createSandbox(id)
@@ -180,10 +211,13 @@ export function apply(ctx: Context, config: Config): void {
// it mounted but tell the model what it is waiting for.
const missing = missingServices(ctx, fiber)
const state = STATE_LABELS[fiber.state]
const note = missing.length > 0
? ` — waiting for service(s): ${missing.join(', ')} (activates when provided)`
: ''
return [{ type: 'text', text: `mounted ${id} (plugin "${pluginName(evaluated)}", state: ${state}${note})` }]
return {
id,
pluginName: pluginName(evaluated),
state,
provides: providedServices(ctx, fiber),
waitingFor: missing,
}
},
presentCall: presentMountCall,
}))
@@ -202,6 +236,17 @@ export function apply(ctx: Context, config: Config): void {
description: 'The dynamic mount id returned by cordis_mount (e.g. "dyn-1").',
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'string', required: true },
pluginName: { type: 'string', required: true },
},
},
render: (_args, value) => [{ type: 'text', text: `unmounted ${value.id} (plugin "${value.pluginName}")` }],
},
async execute(args) {
const mount = mounts.get(args.id)
if (!mount) {
@@ -209,7 +254,7 @@ export function apply(ctx: Context, config: Config): void {
}
await mount.fiber.dispose()
mounts.delete(args.id)
return [{ type: 'text', text: `unmounted ${args.id} (plugin "${mount.pluginName}")` }]
return { id: args.id, pluginName: mount.pluginName }
},
presentCall: presentUnmountCall,
}))

View File

@@ -33,8 +33,13 @@ function withinFiber(fiber: Fiber, root: Fiber): boolean {
}
}
/** The service names provided by a mount's fiber subtree, sorted. */
function providedBy(ctx: Context, fiber: Fiber): string[] {
/**
* Return the service names provided by a mount's fiber subtree.
* @param ctx - the runtime whose service registrations are inspected.
* @param fiber - the root of the mounted fiber subtree.
* @returns the provided service names in lexical order.
*/
export function providedServices(ctx: Context, fiber: Fiber): string[] {
return liveImpls(ctx)
.filter(impl => withinFiber(impl.fiber, fiber))
.map(impl => impl.name)
@@ -96,7 +101,7 @@ export function describeTools(ctx: Context, scope?: ScopeKey): string[] {
export function describeDynamic(ctx: Context, mounts: ReadonlyMap<string, DynamicMount>): string[] {
if (mounts.size === 0) return ['(no dynamic plugins mounted)']
return [...mounts].map(([id, mount]) => {
const provides = providedBy(ctx, mount.fiber)
const provides = providedServices(ctx, mount.fiber)
const waiting = missingServices(ctx, mount.fiber)
const providesNote = provides.length > 0 ? ` — provides: ${provides.join(', ')}` : ''
const waitingNote = waiting.length > 0 ? ` — waiting for: ${waiting.join(', ')}` : ''

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { call, CONSUMER_CODE, PROVIDER_CODE, setup, text } from './helpers.ts'
import { call, CONSUMER_CODE, CONTENT_OUTPUT_CODE, PROVIDER_CODE, setup, text } from './helpers.ts'
/**
* Cross-mount composition through ordinary cordis provide/inject semantics:
@@ -116,6 +116,7 @@ describe('cross-mount provide/inject', () => {
name: 'answer',
description: 'Read the provided primitive services.',
parameters: {},
${CONTENT_OUTPUT_CODE}
async execute() {
return [{ type: 'text', text: ctx.answer + '/' + ctx.get('answer') + '/' + ctx.nothing }]
},

View File

@@ -47,6 +47,13 @@ export const LISTENER_CODE = `
}
`
/** Explicit content-array output declaration for dynamic-tool behavior fixtures. */
export const CONTENT_OUTPUT_CODE = `
output: {
schema: { type: 'array', items: { type: 'json' } },
render(_args, value) { return value },
},`
/** Mount code for a self-made tool: registers `reverse_text` via the sandbox's harness helpers. */
export const REVERSE_TOOL_CODE = `
return {
@@ -57,8 +64,14 @@ export const REVERSE_TOOL_CODE = `
name: 'reverse_text',
description: 'Reverse a string.',
parameters: { text: { type: 'string', required: true } },
output: {
schema: { type: 'string' },
render(_args, value) {
return [{ type: 'text', text: value }]
},
},
async execute(args) {
return [{ type: 'text', text: args.text.split('').reverse().join('') }]
return args.text.split('').reverse().join('')
},
}))
},
@@ -85,8 +98,14 @@ export const CONSUMER_CODE = `
name: 'greet',
description: 'Greet someone via the greeter service.',
parameters: { name: { type: 'string', required: true } },
output: {
schema: { type: 'string' },
render(_args, value) {
return [{ type: 'text', text: value }]
},
},
async execute(args) {
return [{ type: 'text', text: ctx.greeter.greet(args.name) }]
return ctx.greeter.greet(args.name)
},
}))
},
@@ -99,8 +118,9 @@ export function dummyTool(name: string): ToolDefinition {
name,
description: 'test trigger',
parameters: { type: 'object' as const, properties: {} },
async execute(): Promise<[]> {
return []
output: { schema: { type: 'null' }, render: () => [] },
async execute(): Promise<null> {
return null
},
}
}

View File

@@ -16,6 +16,8 @@ describe('cordis_inspect', () => {
const result = await call(ctx, 'cordis_inspect', {})
expect(result.isError).toBe(false)
const report = text(result)
if (result.isError) throw new Error('expected cordis_inspect success')
expect(result.value).toBe(report)
for (const heading of ['services', 'plugins', 'tools', 'dynamic', 'api', 'events']) {
expect(report).toContain(`## ${heading}`)
}

View File

@@ -1,7 +1,8 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { isJsonValue } from '@deepseek-ai/dsh-session'
import { sandboxDefineTool } from '../src/guard.ts'
import { syntaxErrorContext } from '../src/sandbox.ts'
import { call, dummyTool, LISTENER_CODE, REVERSE_TOOL_CODE, setup, text } from './helpers.ts'
import { call, CONTENT_OUTPUT_CODE, dummyTool, LISTENER_CODE, REVERSE_TOOL_CODE, setup, text } from './helpers.ts'
/**
* The `cordis_mount` success/failure family: real plugins land on a genuine
@@ -14,12 +15,48 @@ afterEach(() => {
})
describe('cordis_mount', () => {
it.each([
[42, 'options must be an object'],
[{ parameters: {} }, 'output must declare { schema, render, presentationMeta? }'],
[{ parameters: {}, output: { schema: { type: 'json' } }, execute: async (): Promise<null> => null }, 'output.render must be a function'],
[{ parameters: {}, output: { schema: { type: 'json' }, render: () => [] }, execute: true }, 'execute must be a function'],
[{
parameters: {},
output: { schema: { type: 'json' }, render: () => [], presentationMeta: true },
execute: async (): Promise<null> => null,
}, 'output.presentationMeta must be a function'],
])('rejects an invalid dynamic tool declaration before registration: %j', (definition, message) => {
expect(() => sandboxDefineTool(definition)).toThrow(message)
})
it('bounds the preview of an invalid dynamic renderer return', () => {
const definition = sandboxDefineTool({
name: 'invalid-renderer',
description: 'invalid renderer',
parameters: {},
output: {
schema: { type: 'string' },
render: () => ['x'.repeat(500)],
},
execute: async () => 'ok',
})
expect(() => definition.output.render({}, 'ok')).toThrow(/output\.render returned \["x+…/)
})
it('mounts a listener plugin that observes real events, tagged-logging through to the host console', async () => {
const ctx = await setup()
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const result = await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected cordis_mount success')
expect(result.value).toEqual({
id: 'dyn-1',
pluginName: 'change-logger',
state: 'active',
provides: [],
waitingFor: [],
})
expect(text(result)).toContain('mounted dyn-1 (plugin "change-logger", state: active)')
// Fire a REAL tools/change by registering a tool; the mounted listener logs.
@@ -44,6 +81,8 @@ describe('cordis_mount', () => {
expect(ctx.tools.schemas().map(schema => schema.name)).toContain('reverse_text')
const reversed = await call(ctx, 'reverse_text', { text: 'harness' })
expect(reversed.isError).toBe(false)
if (reversed.isError) throw new Error('expected dynamic tool success')
expect(reversed.value).toBe('ssenrah')
expect(text(reversed)).toBe('ssenrah')
})
@@ -55,7 +94,7 @@ describe('cordis_mount', () => {
expect(isJsonValue({ content: reversed.content, isError: reversed.isError })).toBe(true)
})
it('threads the { content, meta } object return form through to the registry result', async () => {
it('projects presentation metadata from a dynamic canonical value', async () => {
const ctx = await setup()
await call(ctx, 'cordis_mount', {
code: `
@@ -67,8 +106,13 @@ describe('cordis_mount', () => {
name: 'meta_tool',
description: 'attaches a private presentation payload',
parameters: {},
output: {
schema: { type: 'string' },
render(_args, value) { return [{ type: 'text', text: value }] },
presentationMeta() { return { kind: 'demo' } },
},
async execute() {
return { content: [{ type: 'text', text: 'ok' }], meta: { kind: 'demo' } }
return 'ok'
},
}))
},
@@ -77,20 +121,20 @@ describe('cordis_mount', () => {
})
const result = await call(ctx, 'meta_tool', {})
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected dynamic tool success')
expect(result.value).toBe('ok')
expect(text(result)).toBe('ok')
expect(result.meta).toEqual({ kind: 'demo' })
})
it.each([
['a bare string', 'return \'ok\'', '"ok"'],
['an object whose content is a string', 'return { content: \'ok\' }', '{"content":"ok"}'],
['an array of non-objects', 'return [\'ok\']', '["ok"]'],
['blocks missing the type tag', 'return [{ text: \'hi\' }]', '[{"text":"hi"}]'],
['object-form blocks missing the type tag', 'return { content: [{ text: \'hi\' }] }', '{"content":[{"text":"hi"}]}'],
['undefined — a forgotten return', 'return undefined', 'undefined'],
])('rejects an execute return of %s as that one call\'s teaching error', async (_label, returnStatement, preview) => {
// The registry spreads result.content, so { content: 'ok' } would become ['o','k']; reject
// it as this call's error before it corrupts the next request.
['a bare string', 'return \'ok\'', 'returned invalid output: "value" must be an array'],
['an object whose content is a string', 'return { content: \'ok\' }', 'returned invalid output: "value" must be an array'],
['an array of non-objects', 'return [\'ok\']', 'output.render returned ["ok"]'],
['blocks missing the type tag', 'return [{ text: \'hi\' }]', 'output.render returned [{"text":"hi"}]'],
['object-form blocks missing the type tag', 'return { content: [{ text: \'hi\' }] }', 'returned invalid output: "value" must be an array'],
['undefined — a forgotten return', 'return undefined', 'execute result must be lossless JSON data'],
])('rejects an execute return of %s against its declared output', async (_label, returnStatement, diagnostic) => {
const ctx = await setup()
await call(ctx, 'cordis_mount', {
code: `
@@ -102,6 +146,7 @@ describe('cordis_mount', () => {
name: 'bad_return_tool',
description: 'returns a wrong shape',
parameters: {},
${CONTENT_OUTPUT_CODE}
async execute() { ${returnStatement} },
}))
},
@@ -112,12 +157,10 @@ describe('cordis_mount', () => {
expect(result.isError).toBe(true)
expect(result.content).toHaveLength(1)
expect(result.content[0]!.type).toBe('text')
expect(text(result)).toContain(`execute returned ${preview}`)
expect(text(result)).toContain('must return an ARRAY of content blocks')
expect(text(result)).toContain('✓ return { content: [{ type: \'text\', text: someString }], meta: anyJsonValue }')
expect(text(result)).toContain(diagnostic)
})
it('truncates a huge invalid execute return in the teaching error', async () => {
it('does not echo a huge schema-invalid canonical value in the diagnostic', async () => {
const ctx = await setup()
await call(ctx, 'cordis_mount', {
code: `
@@ -129,6 +172,7 @@ describe('cordis_mount', () => {
name: 'huge_return_tool',
description: 'returns a huge wrong shape',
parameters: {},
${CONTENT_OUTPUT_CODE}
async execute() { return 'x'.repeat(500) },
}))
},
@@ -137,7 +181,7 @@ describe('cordis_mount', () => {
})
const result = await call(ctx, 'huge_return_tool', {})
expect(result.isError).toBe(true)
expect(text(result)).toContain('')
expect(text(result)).toContain('returned invalid output')
expect(text(result)).not.toContain('x'.repeat(200))
})
@@ -156,14 +200,18 @@ describe('cordis_mount', () => {
description: 'written in the JSON-Schema dialect',
parameters: {
type: 'object',
title: 'Raw parameters',
default: { text: 'default' },
examples: [{ text: 'example' }],
properties: {
text: { type: 'string', description: 'the text' },
count: { type: 'integer', default: 1 },
mode: { type: 'string', enum: ['fast', 'slow'] },
extra: { type: 'string', required: false },
extra: { type: 'string' },
},
required: ['text'],
},
${CONTENT_OUTPUT_CODE}
async execute(args) { return [{ type: 'text', text: args.text + ':' + (args.count ?? 0) }] },
}))
},
@@ -173,14 +221,19 @@ describe('cordis_mount', () => {
expect(result.isError).toBe(false)
// The registered schema is canonical JSON Schema derived from the DSL:
// the required array survived, integer became number, extra is optional.
// the required array survived, integer stayed integer, extra is optional.
const schema = ctx.tools.schemas().find(s => s.name === 'json_schema_tool')!
const parameters = schema.parameters as {
properties: Record<string, { type: string; enum?: string[]; default?: unknown }>
required?: string[]
}
expect(parameters.required).toEqual(['text'])
expect(parameters.properties.count!.type).toBe('number')
expect(parameters).toMatchObject({
title: 'Raw parameters',
default: { text: 'default' },
examples: [{ text: 'example' }],
})
expect(parameters.properties.count!.type).toBe('integer')
expect(parameters.properties.count!.default).toBe(1)
expect(parameters.properties.mode!.enum).toEqual(['fast', 'slow'])
// Arg validation enforces the normalized spec: text required, extra not.
@@ -202,8 +255,12 @@ describe('cordis_mount', () => {
name: 'nested_json_schema_tool',
description: 'nested dialect',
parameters: {
cfg: { type: 'object', properties: { label: { type: 'string' } }, required: ['label'] },
type: 'object',
properties: {
cfg: { type: 'object', properties: { label: { type: 'string' } }, required: ['label'] },
},
},
${CONTENT_OUTPUT_CODE}
async execute(args) { return [{ type: 'text', text: args.cfg.label }] },
}))
},
@@ -217,14 +274,198 @@ describe('cordis_mount', () => {
expect(text(await call(ctx, 'nested_json_schema_tool', { cfg: { label: 'hi' } }))).toBe('hi')
})
it('normalizes every unified DSL node and lossless annotation shape across the sandbox realm', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'unified-schema',
inject: ['tools'],
apply(ctx) {
harness.registerTool(ctx, harness.defineTool({
name: 'unified_schema_tool',
description: 'all unified nodes',
parameters: {
any: {
type: 'json',
title: 'Any JSON',
default: { nested: [1, 'x', null] },
examples: [{ ok: true }],
},
choice: {
oneOf: [{ type: 'string', const: 'x' }, { type: 'null' }],
required: true,
},
flags: { type: 'array' },
closed: { type: 'object', additionalProperties: false },
count: { type: 'number', enum: [1, 2], const: 1 },
},
${CONTENT_OUTPUT_CODE}
async execute(args) { return [{ type: 'text', text: String(args.choice) }] },
}))
},
}
`,
})
expect(result.isError).toBe(false)
const schema = ctx.tools.schemas().find(s => s.name === 'unified_schema_tool')!
expect(schema.parameters).toMatchObject({
properties: {
any: { title: 'Any JSON', default: { nested: [1, 'x', null] }, examples: [{ ok: true }] },
choice: { oneOf: [{ type: 'string', const: 'x' }, { type: 'null' }] },
flags: { type: 'array' },
closed: { type: 'object', additionalProperties: false },
count: { type: 'number', enum: [1, 2], const: 1 },
},
required: ['choice'],
})
})
it('normalizes and snapshots deeply nested sandbox schemas and annotations stack-safely', async () => {
const ctx = await setup()
const depth = 5_000
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'deep-unified-schema',
inject: ['tools'],
apply(ctx) {
let choice = { type: 'string' }
let example = 'leaf'
for (let index = 0; index < ${depth}; index++) {
choice = { oneOf: [choice, { type: 'null' }] }
example = [example]
}
harness.registerTool(ctx, harness.defineTool({
name: 'deep_unified_schema_tool',
description: 'deep unified nodes',
parameters: {
choice: { ...choice, required: true },
any: { type: 'json', default: example },
},
${CONTENT_OUTPUT_CODE}
async execute() { return [] },
}))
},
}
`,
})
expect(result.isError).toBe(false)
const parameters = ctx.tools.schemas().find(s => s.name === 'deep_unified_schema_tool')!.parameters as {
properties: Record<string, Record<string, unknown>>
}
let choice = parameters.properties.choice!
let choiceDepth = 0
while (Array.isArray(choice.oneOf)) {
choice = choice.oneOf[0] as Record<string, unknown>
choiceDepth++
}
let example: unknown = parameters.properties.any!.default
let exampleDepth = 0
while (Array.isArray(example)) {
example = example[0]
exampleDepth++
}
expect({ choiceDepth, choice, exampleDepth, example }).toEqual({
choiceDepth: depth,
choice: { type: 'string' },
exampleDepth: depth,
example: 'leaf',
})
})
it('normalizes unconstrained and closed nested nodes from a raw JSON Schema wrapper', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'raw-unified-schema',
inject: ['tools'],
apply(ctx) {
harness.registerTool(ctx, harness.defineTool({
name: 'raw_unified_schema_tool',
description: 'raw unified nodes',
parameters: {
type: 'object',
additionalProperties: true,
properties: {
any: { description: 'unconstrained' },
cfg: {
type: 'object',
additionalProperties: false,
properties: { label: { type: 'string' } },
required: ['label'],
},
choice: { oneOf: [{ type: 'boolean' }, { type: 'null' }] },
},
},
${CONTENT_OUTPUT_CODE}
async execute() { return [] },
}))
},
}
`,
})
expect(result.isError).toBe(false)
expect(ctx.tools.schemas().find(s => s.name === 'raw_unified_schema_tool')!.parameters).toMatchObject({
properties: {
any: {},
cfg: { additionalProperties: false, required: ['label'] },
choice: { oneOf: [{ type: 'boolean' }, { type: 'null' }] },
},
})
})
it.each([
['parameters: 42', 'must be a SchemaSpec object'],
['parameters: { text: 42 }', 'parameters.text must be a SchemaSpec property object'],
['parameters: { text: { type: \'str\' } }', 'parameters.text must declare a valid type: \'string\' | \'number\' | \'boolean\' | \'object\' | \'array\' (got "str")'],
['parameters: { text: { type: \'string\', required: \'yes\' } }', 'parameters.text.required must be a boolean when present'],
['parameters: { text: { type: \'string\', properties: {} } }', 'parameters.text.properties is only valid for type "object"'],
['parameters: { text: { type: \'string\', items: { type: \'string\' } } }', 'parameters.text.items is only valid for type "array"'],
])('rejects a malformed SchemaSpec (%s) with a teaching error', async (parameters, message) => {
['parameters: 42', 'must be a ParameterSchemaSpec object'],
['parameters: Object.defineProperty({}, \'text\', { value: { type: \'string\' } })', 'parameters must contain only own enumerable string keys'],
['parameters: { text: 42 }', 'parameters.text must be a ParameterSchemaSpec property object'],
['parameters: { text: Object.defineProperty({ type: \'string\' }, \'minimum\', { value: 1 }) }', 'parameters.text must contain only own enumerable string keys'],
['parameters: { text: { type: \'string\', [Symbol(\'hidden\')]: true } }', 'parameters.text must contain only own enumerable string keys'],
['parameters: { text: { type: \'str\' } }', 'parameters.text must declare a valid type: \'string\' | \'number\' | \'integer\' | \'boolean\' | \'null\' | \'object\' | \'array\' | \'json\' (got "str")'],
['parameters: { text: { type: \'string\', required: \'yes\' } }', 'parameters.text.required must be true when present'],
['parameters: { text: { type: \'string\', properties: {} } }', 'parameters.text.properties is not supported by the unified schema DSL'],
['parameters: { text: { type: \'string\', items: { type: \'string\' } } }', 'parameters.text.items is not supported by the unified schema DSL'],
['parameters: { text: { type: \'object\', properties: {} } }', 'parameters.text.additionalProperties must be explicitly true or false'],
['parameters: { text: { type: \'object\', additionalProperties: \'no\' } }', 'parameters.text.additionalProperties must be explicitly true or false'],
['parameters: { type: \'object\' }', 'parameters.properties must be an object of schemas'],
['parameters: { type: \'object\', properties: {}, additionalProperties: false }', 'parameters.additionalProperties must be true or omitted'],
['parameters: { type: \'object\', properties: {}, required: \'text\' }', 'parameters.required must be an array of declared property names'],
['parameters: { type: \'object\', properties: {}, required: undefined }', 'parameters.required must be an array of declared property names'],
['parameters: { type: \'object\', properties: {}, required: [42] }', 'parameters.required must be an array of declared property names'],
['parameters: (() => { const required = []; required.length = 1; return { type: \'object\', properties: {}, required } })()', 'parameters.required must be an array of declared property names'],
['parameters: (() => { const required = []; required.length = 1; required.extra = true; return { type: \'object\', properties: {}, required } })()', 'parameters.required must be an array of declared property names'],
['parameters: (() => { class Names extends Array { *[Symbol.iterator]() {} }; const required = new Names(); required[0] = \'text\'; required.length = 1; return { type: \'object\', properties: { text: { type: \'string\' } }, required } })()', 'parameters.required must be an array of declared property names'],
['parameters: { type: \'object\', properties: {}, required: [\'text\'] }', 'parameters.required names undeclared property "text"'],
['parameters: { type: \'object\', properties: { text: { type: \'string\', required: true } } }', 'parameters.text.required belongs to the containing raw object schema'],
['parameters: { type: \'object\', properties: { text: { oneOf: \'bad\' } } }', 'parameters.text.oneOf must contain at least two schemas'],
['parameters: { type: \'object\', properties: { text: { type: \'json\' } } }', 'parameters.text must declare a valid type'],
['parameters: { type: \'object\', properties: { cfg: { type: \'object\', additionalProperties: \'no\' } } }', 'parameters.cfg.additionalProperties must be a boolean'],
['parameters: { type: \'object\', properties: { cfg: { type: \'object\', properties: 42 } } }', 'parameters.cfg.properties must be an object of schemas'],
['parameters: { type: \'object\', properties: { cfg: { type: \'object\', required: [\'label\'] } } }', 'parameters.cfg.required names undeclared property "label"'],
['parameters: { type: \'object\', properties: { cfg: { type: \'object\', required: undefined } } }', 'parameters.cfg.required must be an array of declared property names'],
['parameters: { value: { oneOf: \'bad\' } }', 'parameters.value.oneOf must contain at least two schemas'],
['parameters: { value: { oneOf: new (class Branches extends Array {})({ type: \'string\' }, { type: \'null\' }) } }', 'parameters.value.oneOf must contain at least two schemas'],
['parameters: { value: { oneOf: Object.assign([{ type: \'string\' }, { type: \'null\' }], { extra: true }) } }', 'parameters.value.oneOf must contain at least two schemas'],
['parameters: { value: { type: \'string\', enum: \'bad\' } }', 'enum must be a non-empty array'],
['parameters: { value: { type: \'string\', enum: new (class Values extends Array {})(\'a\', \'b\') } }', 'parameters.value.enum must be a non-empty array'],
['parameters: { value: { type: \'json\', default: -0 } }', 'parameters.value.default must be lossless JSON data'],
['parameters: { value: { type: \'json\', default: Infinity } }', 'parameters.value.default must be lossless JSON data'],
['parameters: { value: { type: \'json\', default: () => 1 } }', 'parameters.value.default must be lossless JSON data'],
['parameters: { value: { type: \'json\', default: (() => { const v = {}; v.self = v; return v })() } }', 'parameters.value.default.self must be lossless JSON data'],
['parameters: { value: { type: \'json\', default: Array(2) } }', 'parameters.value.default must be lossless JSON data'],
['parameters: { value: { type: \'json\', default: Object.assign([1], { extra: true }) } }', 'parameters.value.default must be lossless JSON data'],
['parameters: { value: { type: \'json\', default: (() => { const v = Array(1); v.extra = true; return v })() } }', 'parameters.value.default must be lossless JSON data'],
['parameters: { value: { type: \'json\', default: Object.defineProperty({}, \'hidden\', { value: true }) } }', 'parameters.value.default must be lossless JSON data'],
['parameters: { value: { type: \'json\', default: { [Symbol(\'hidden\')]: true } } }', 'parameters.value.default must be lossless JSON data'],
['parameters: { value: { type: \'json\', default: new (class DefaultValue { constructor() { this.ok = true } })() } }', 'parameters.value.default must be lossless JSON data'],
['parameters: { value: { type: \'json\', default: new (class DefaultList extends Array {})() } }', 'parameters.value.default must be lossless JSON data'],
['parameters: { value: { type: \'json\', default: new Date(0) } }', 'parameters.value.default must be lossless JSON data'],
['parameters: (() => { const p = Object.create(null); const C = function C() {}; Object.defineProperty(C, \'name\', { value: \'Object\' }); C.prototype = p; Object.defineProperty(p, \'constructor\', { value: C }); return Object.create(p) })()', 'must be a ParameterSchemaSpec object'],
['parameters: (() => { const p = Object.create(null); const C = function C() {}; Object.defineProperty(C, \'name\', { value: \'Object\' }); C.prototype = p; const r = Proxy.revocable(C, {}); Object.defineProperty(p, \'constructor\', { value: r.proxy }); r.revoke(); return Object.create(p) })()', 'must be a ParameterSchemaSpec object'],
['parameters: Object.create(Object.create(null))', 'must be a ParameterSchemaSpec object'],
])('rejects a malformed ParameterSchemaSpec (%s) with a teaching error', async (parameters, message) => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: `
@@ -236,6 +477,7 @@ describe('cordis_mount', () => {
name: 'bad_schema_tool',
description: 'bad',
${parameters},
${CONTENT_OUTPUT_CODE}
async execute() { return [] },
}))
},
@@ -246,7 +488,83 @@ describe('cordis_mount', () => {
expect(text(result)).toContain(message)
})
it('accepts a nested object/array SchemaSpec (the DSL recursion)', async () => {
it.each([
[
`
const parameters = {}
const item = { type: 'array' }
item.items = item
parameters.item = item
`,
'parameters.item.items is circular',
],
[
`
const parameters = {}
const item = { type: 'object', additionalProperties: true, properties: parameters }
parameters.item = item
`,
'parameters.item.properties is circular',
],
])('rejects circular sandbox schemas without exhausting the call stack', async (declaration, message) => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'circular-schema',
inject: ['tools'],
apply(ctx) {
${declaration}
harness.registerTool(ctx, harness.defineTool({
name: 'circular_schema_tool',
description: 'circular',
parameters,
async execute() { return [] },
}))
},
}
`,
})
expect(result.isError).toBe(true)
expect(text(result)).toContain(message)
})
it('preserves literal __proto__ keys in sandbox schemas and annotations', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'proto-schema',
inject: ['tools'],
apply(ctx) {
harness.registerTool(ctx, harness.defineTool({
name: 'proto_schema_tool',
description: 'literal JSON keys',
parameters: {
['__proto__']: { type: 'string', required: true },
value: { type: 'json', default: { ['__proto__']: { safe: true } } },
},
${CONTENT_OUTPUT_CODE}
async execute() { return [] },
}))
},
}
`,
})
expect(result.isError).toBe(false)
const parameters = ctx.tools.schemas().find(schema => schema.name === 'proto_schema_tool')!.parameters as {
properties: Record<string, { default?: unknown }>
required?: string[]
}
expect(Object.hasOwn(parameters.properties, '__proto__')).toBe(true)
expect(parameters.required).toContain('__proto__')
const defaultValue = parameters.properties.value!.default as Record<string, unknown>
expect(Object.hasOwn(defaultValue, '__proto__')).toBe(true)
expect(defaultValue.__proto__).toEqual({ safe: true })
})
it('accepts a nested object/array ParameterSchemaSpec (the DSL recursion)', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: `
@@ -258,9 +576,10 @@ describe('cordis_mount', () => {
name: 'nested_schema_tool',
description: 'nested',
parameters: {
item: { type: 'object', required: true, properties: { label: { type: 'string', required: true } } },
item: { type: 'object', additionalProperties: true, required: true, properties: { label: { type: 'string', required: true } } },
tags: { type: 'array', items: { type: 'string' } },
},
${CONTENT_OUTPUT_CODE}
async execute(args) { return [{ type: 'text', text: args.item.label }] },
}))
},
@@ -284,6 +603,7 @@ describe('cordis_mount', () => {
name: 'raw_dynamic_tool',
description: 'raw',
parameters: { type: 'object', properties: {} },
${CONTENT_OUTPUT_CODE}
async execute() { return [] },
})
},
@@ -337,6 +657,14 @@ describe('cordis_mount', () => {
code: 'return { name: \'waiter\', inject: [\'no-such-service\'], apply(ctx) {} }',
})
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected pending cordis_mount success')
expect(result.value).toEqual({
id: 'dyn-1',
pluginName: 'waiter',
state: 'pending',
provides: [],
waitingFor: ['no-such-service'],
})
expect(text(result)).toContain('state: pending')
expect(text(result)).toContain('waiting for service(s): no-such-service')
// Unmounting a pending mount works like any other.
@@ -397,6 +725,7 @@ describe('cordis_mount', () => {
name: 'cordis_mount',
description: 'dup',
parameters: {},
${CONTENT_OUTPUT_CODE}
async execute() { return [] },
}))
},
@@ -543,6 +872,7 @@ describe('cordis_mount', () => {
name: 'probe_instanceof',
description: 'report instanceof checks across realms',
parameters: { items: { type: 'array', required: true, items: { type: 'string' } } },
${CONTENT_OUTPUT_CODE}
async execute(args) {
const checks = {
hostArray: args.items instanceof Array,

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { call, setup, text } from './helpers.ts'
import { call, CONTENT_OUTPUT_CODE, setup, text } from './helpers.ts'
/**
* The sandbox context façade is a whitelist, not a pass-through proxy. Mounted code reaches only
@@ -51,6 +51,7 @@ describe('sandbox context façade — escape surface is closed', () => {
name: 'smuggled',
description: 'raw, unguarded',
parameters: { type: 'object', properties: {} },
${CONTENT_OUTPUT_CODE}
async execute() { return [] },
})
},
@@ -87,6 +88,7 @@ describe('sandbox context façade — escape surface is closed', () => {
name: 'smuggled_via_service',
description: 'raw, unguarded',
parameters: { type: 'object', properties: {} },
${CONTENT_OUTPUT_CODE}
async execute() { return [] },
})
},
@@ -116,6 +118,7 @@ describe('sandbox context façade — escape surface is closed', () => {
name: 'do_fetch',
description: 'awaits the host async service',
parameters: {},
${CONTENT_OUTPUT_CODE}
async execute() {
const value = await ctx.hostAsync.grab()
return [{ type: 'text', text: value }]
@@ -203,6 +206,7 @@ describe('sandbox context façade — inject gate on services', () => {
name: 'greet_undeclared',
description: 'uses greeter without declaring it',
parameters: { n: { type: 'string', required: true } },
${CONTENT_OUTPUT_CODE}
async execute(args) { return [{ type: 'text', text: ctx.greeter.greet(args.n) }] },
}))
},
@@ -235,6 +239,7 @@ describe('sandbox tools façade — get is a read-only schema view', () => {
name: 'report_view',
description: 'reports the shape of a tool view',
parameters: {},
${CONTENT_OUTPUT_CODE}
async execute() {
const view = ctx.tools.get('cordis_mount')
return [{ type: 'text', text: JSON.stringify({
@@ -270,6 +275,7 @@ describe('sandbox tools façade — get is a read-only schema view', () => {
name: 'probe_unknown',
description: 'reports whether an unknown tool resolves',
parameters: {},
${CONTENT_OUTPUT_CODE}
async execute() {
return [{ type: 'text', text: String(ctx.tools.get('no_such_tool') === undefined) }]
},

View File

@@ -26,6 +26,8 @@ describe('cordis_unmount', () => {
const result = await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected cordis_unmount success')
expect(result.value).toEqual({ id: 'dyn-1', pluginName: 'change-logger' })
expect(text(result)).toContain('unmounted dyn-1')
// Immediately after the awaited unmount, the listener must be gone — no

View File

@@ -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 } : {},

View File

@@ -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' } },

View File

@@ -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: {},

View File

@@ -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' } },

View File

@@ -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: {},

View File

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

View File

@@ -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' } },

View File

@@ -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.' } },

View File

@@ -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' } },

View File

@@ -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: {},

View File

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

View File

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

View File

@@ -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: {},

View File

@@ -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' } },

View File

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

View File

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

View File

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

View File

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

View File

@@ -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. 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/unsupported output declarations and a non-positive/non-finite `timeoutMs` fail at registration. 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 followed by the ob
### Key types
- `ToolDefinition``ToolSchema` + `execute(args, exec)`, whose async work must cooperatively stop through `exec.signal`, plus optional presentation callbacks, cooperative `timeoutMs`, and optional per-call `isConcurrencySafe(args)` classification.
- `ToolDefinition``ToolSchema` + mandatory `output { schema, render, presentationMeta? }` + `execute(args, exec)`, optional 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`.
- `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 followed by the ob
- 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; `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; `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).

View File

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

View File

@@ -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>
/**
* Cooperative tool-call timeout budget in milliseconds. Omit for no deadline.
* Enforced by `@deepseek-ai/dsh-timeout-policy` (a `tools/execute` wrapper); it
@@ -173,7 +203,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.
@@ -183,17 +213,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
@@ -325,6 +354,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
@@ -338,30 +375,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
@@ -374,11 +454,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[] }
/**
@@ -403,6 +484,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 {
@@ -569,7 +667,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))
},
})
}
@@ -622,6 +720,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)) {
@@ -755,13 +860,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,
}
}
@@ -895,10 +1021,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)) {
@@ -951,13 +1078,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
@@ -984,18 +1105,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
@@ -1139,32 +1261,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 } : {},
})
}
/** 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)
}
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 {
const detached = snapshotJsonValue(result)
if (detached === undefined) {
throw new TypeError('tool result must be losslessly JSON-serializable')
const presentation = {
content: result.content,
...result.meta !== undefined ? { meta: result.meta } : {},
...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {},
}
return deepFreeze(detached)
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 })
}
}
@@ -1175,10 +1378,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 } : {} },
}
}
@@ -1226,7 +1430,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 } : {},
}
}
@@ -1237,7 +1444,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 } : {},
}
}

View File

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

View File

@@ -1,173 +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 type { ToolDefinition, ToolExecuteReturn, ToolRunContext, ToolResult } from './index.ts'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { JsonValue } from '@deepseek-ai/dsh-session'
import type { ToolDefinition, 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[]) {
@@ -177,155 +469,81 @@ 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: 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 presenters.
* @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 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 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
@@ -334,41 +552,46 @@ 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>
},
}
// Presentation is display-only and may run on REPLAY of arbitrary logged args
// (possibly from an older schema), so it must never throw: validate softly and
// fall back to `undefined` (a generic UI presentation) on any mismatch, rather
// 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>)
}
}

View 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[]
},
})
}

View File

@@ -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\`\`\``
}

View File

@@ -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([])
})

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -219,7 +219,8 @@ describe('dsh-acp-demo composition', () => {
name,
description: name,
parameters: {},
execute: async () => [],
output: { schema: { type: 'null' }, render: () => [] },
execute: async () => null,
})
}
const assembly = await ctx.get('systemPrompt')!.assemble()

View File

@@ -562,7 +562,8 @@ describe('dsh-agent-spine-demo bundle', () => {
name,
description: name,
parameters: {},
execute: async () => [],
output: { schema: { type: 'null' }, render: () => [] },
execute: async () => null,
})
}
const assembly = await ctx.get('systemPrompt')!.assemble()

View File

@@ -104,7 +104,13 @@ describe('dsh-cli-demo app composition', () => {
})
ctx.skills.register({ name: 'cli-skill', description: 'CLI skill', source: 'runtime', content: 'body' })
for (const name of ['alpha', 'zulu']) {
ctx.tools.register({ name, description: name, parameters: {}, execute: async () => [] })
ctx.tools.register({
name,
description: name,
parameters: {},
output: { schema: { type: 'null' }, render: () => [] },
execute: async () => null,
})
}
expect(JSON.stringify(await composePrefix(ctx))).toContain('- `cli-skill`: CLI...')
expect((await ctx.systemPrompt.assemble()).tools.map(tool => tool.name)).toEqual([

View File

@@ -115,7 +115,11 @@ async function harness(script: readonly ScriptEntry[]): Promise<Harness> {
name: 'echo',
description: 'Echo text.',
parameters: { text: { type: 'string', required: true } },
execute: async args => [{ type: 'text', text: `ECHO: ${(args as { text: string }).text}` }],
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value as string }],
},
execute: async args => `ECHO: ${(args as { text: string }).text}`,
})
const [agent] = ctx.agents.roots()
if (agent === undefined) throw new Error('test main agent missing')

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-tool-fs-search
The **model-facing filesystem discovery tools**`glob`, `grep` — backed by the **bash executor seam**, not by `ctx.fs` provider methods. At load, the package probes `command -v rg` through `ctx.bash`; if the executor cannot find ripgrep on its `PATH`, it logs a warning and registers no tools or prompt sections. Each call assembles a fixed ripgrep command (every model-controlled value through one package-private shell-quoting helper), runs it via `ctx.bash.resolve(request)``ctx.bash.run(spec)` as an ordinary foreground tool call, parses the raw `rg` output, and returns a bounded, workdir-relative result. The package injects `tools`, `systemPrompt`, and `bash`deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional.
The **model-facing filesystem discovery tools**`glob`, `grep`are backed by the **bash executor seam**, not by `ctx.fs` provider methods. At load, the package probes `command -v rg` through `ctx.bash`; if the executor cannot find ripgrep on its `PATH`, it logs a warning and registers no tools or prompt sections. Each call assembles a fixed ripgrep command (every model-controlled value through one package-private shell-quoting helper), runs it via `ctx.bash.resolve(request)``ctx.bash.run(spec)` as an ordinary foreground tool call, parses the raw `rg` output, and returns a workdir-relative canonical value. The package injects `tools`, `systemPrompt`, and `bash`deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional.
```ts ignore-check
// Default deployment: a bash executor whose PATH includes rg, then the discovery tools.
@@ -39,7 +39,7 @@ Routine budgets stay out of the model-facing schema (no `head_limit`/`offset`/`c
## Two budgets, two artifacts
Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam and parses only complete retained stdout; if the executor still returns `stdout.truncated`, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. The model-facing recovery artifact is different: when a search yields more logical results than the inline cap, the tool saves the COMPLETE formatted result through `ctx.spillStore.saveText()` (suggested names `glob-results.txt` / `grep-results.txt`, owner = the calling session, source = the tool execution identity) and appends a footer naming the returned locator and retrieval hint. This is the first tool-owned spill call in the codebase — deliberate, because retention here is item-level: the generic `@deepseek-ai/dsh-spill-policy` only sees the final text on `tools/post-execute`, by which point a capped search has already omitted later paths/matches. A missing spill backend, a call with no session owner, or a `saveText()` failure keeps the inline page and reports that the complete result could not be savednever an `isError`.
Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam and parses only complete retained stdout; if the executor still returns `stdout.truncated`, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. A successful `glob` keeps every acquired path in `{ paths }`; `grep` keeps every acquired `{ path, lineNumber, line }` in `{ matches }`. Inline item and per-line preview caps apply only in the Native renderer. For a direct surface call with more logical results than the inline cap, post-policy best-effort saves the complete formatted preview through `ctx.spillStore.saveText()` and replaces only presentation with a head page plus locator. Nested Code dispatches skip that spill because their full canonical value does not enter model context. Missing/failed spill keeps the inline page and reports that the complete result could not be savednever an `isError`.
## Errors

View File

@@ -12,7 +12,6 @@
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { ItemRetainer } from '@deepseek-ai/dsh-retention'
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
import type { SpillRef } from '@deepseek-ai/dsh-spill'
@@ -20,6 +19,7 @@ import type {} from '@deepseek-ai/dsh-bash'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
import { singleQuote } from './shell-quote.ts'
import { acceptedSurfaceValue } from './surface.ts'
/**
* Default cap on paths retained inline by one `glob` call (the `globMaxResults`
@@ -117,6 +117,14 @@ export function formatGlobOutput(retained: RetainedItems<string>, spillRef: Spil
return `${body}\n\n(Showing ${retained.kept} of ${retained.seen} paths. ${recovery})`
}
/** Retain and format one canonical path list for the Native surface. */
function renderGlobPaths(paths: string[], maxResults: number, spillRef?: SpillRef): string {
if (paths.length === 0) return 'No files found'
const retainer = new ItemRetainer<string>({ kind: 'head', maxItems: maxResults })
for (const path of paths) retainer.push(path)
return formatGlobOutput(retainer.finish(), spillRef)
}
/**
* Pending-call presentation: a search card titled by the pattern (and root).
*
@@ -142,7 +150,7 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
text: 'Use the glob tool — not shell find or ls — to discover files by path pattern. Results are sorted by modification time and include hidden and ignored files.',
})
ctx.tools.register(defineTool({
const tool = defineTool({
name: 'glob',
description: 'Find files whose paths match a glob pattern. Returns matching paths sorted by modification time, '
+ 'including hidden and ignored files (VCS metadata directories are excluded). '
@@ -152,28 +160,44 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
path: { type: 'string', description: 'Directory to search in. Defaults to the session workspace; a relative path resolves against it.' },
},
timeoutMs: caps.timeoutMs,
async execute(args, exec): Promise<ContentBlock[]> {
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
paths: { type: 'array', required: true, items: { type: 'string' } },
},
},
render: (_args, value) => [{ type: 'text', text: renderGlobPaths(value.paths, caps.maxResults) }],
},
async execute(args, exec) {
const input = parseGlobArgs(args)
const run = await runRipgrep(ctx, exec, 'glob', buildGlobCommand(input), caps.rawOutputMaxBytes)
if (run.noMatches) return [{ type: 'text', text: 'No files found' }]
if (run.noMatches) return { paths: [] }
const retainer = new ItemRetainer<string>({ kind: 'head', maxItems: caps.maxResults })
const all: string[] = []
for (const line of run.stdout.split('\n')) {
if (line.length === 0) continue
const displayPath = toWorkdirRelative(line, run.workdir)
all.push(displayPath)
retainer.push(displayPath)
}
const retained = retainer.finish()
// The complete sorted list is the recovery artifact; save it only when
// the inline page omitted paths (an uncapped result needs no spill file).
const spillRef = retained.truncated
? await trySaveFormattedResult(ctx, exec, 'glob-results.txt', all.join('\n'))
: undefined
return [{ type: 'text', text: formatGlobOutput(retained, spillRef) }]
return { paths: all }
},
presentCall: presentGlobCall,
}))
})
ctx.tools.register(tool)
ctx.on('tools/post-execute', async (exec, result, next) => {
const decision = await next()
const value = acceptedSurfaceValue(ctx, tool, exec, result, decision) as { paths: string[] } | undefined
if (value === undefined) return decision
const paths = value.paths
if (paths.length <= caps.maxResults) return decision
const spillRef = await trySaveFormattedResult(ctx, exec, 'glob-results.txt', paths.join('\n'))
return {
kind: 'accept',
content: [{ type: 'text', text: renderGlobPaths(paths, caps.maxResults, spillRef) }],
...decision.additionalContexts !== undefined ? { additionalContexts: decision.additionalContexts } : {},
}
})
}

View File

@@ -13,7 +13,6 @@
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-retention'
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
import type { SpillRef } from '@deepseek-ai/dsh-spill'
@@ -21,6 +20,7 @@ import type {} from '@deepseek-ai/dsh-bash'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { SearchError, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
import { singleQuote } from './shell-quote.ts'
import { acceptedSurfaceValue } from './surface.ts'
/**
* Default cap on flat matches retained inline by one `grep` call (the
@@ -241,6 +241,20 @@ export function formatGrepOutput(retained: RetainedItems<GrepMatch>, spillRef: S
return `${header}\n\n${body}\n\n(${recovery})`
}
/** Apply the Native per-line preview budget without changing the canonical matches. */
function previewGrepMatches(matches: GrepMatch[], maxLineBytes: number): GrepMatch[] {
return matches.map(match => ({ ...match, line: previewLine(match.line, maxLineBytes) }))
}
/** Retain and format one canonical match list for the Native surface. */
function renderGrepMatches(matches: GrepMatch[], maxMatches: number, maxLineBytes: number, spillRef?: SpillRef): string {
if (matches.length === 0) return 'No matches found'
const previewed = previewGrepMatches(matches, maxLineBytes)
const retainer = new ItemRetainer<GrepMatch>({ kind: 'head', maxItems: maxMatches })
for (const match of previewed) retainer.push(match)
return formatGrepOutput(retainer.finish(), spillRef)
}
/**
* Pending-call presentation: a search card titled by the pattern (and target /
* include filter).
@@ -268,7 +282,7 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
text: 'Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context.',
})
ctx.tools.register(defineTool({
const tool = defineTool({
name: 'grep',
description: 'Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. '
+ `Returns the first ${caps.maxMatches} matches inline; a capped result reports where the complete match list was saved. `
@@ -279,37 +293,70 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
include: { type: 'string', description: 'One glob filter for which files to search (e.g. "*.ts", "*.{js,jsx}"). Not a list; negation is not supported.' },
},
timeoutMs: caps.timeoutMs,
async execute(args, exec): Promise<ContentBlock[]> {
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
matches: {
type: 'array',
required: true,
items: {
type: 'object',
additionalProperties: false,
properties: {
path: { type: 'string', required: true },
lineNumber: { type: 'integer', required: true },
line: { type: 'string', required: true },
},
},
},
},
},
render: (_args, value) => [{
type: 'text',
text: renderGrepMatches(value.matches, caps.maxMatches, caps.maxLineBytes),
}],
},
async execute(args, exec) {
const input = parseGrepArgs(args)
const run = await runRipgrep(ctx, exec, 'grep', buildGrepCommand(input), caps.rawOutputMaxBytes)
if (run.noMatches) return [{ type: 'text', text: 'No matches found' }]
if (run.noMatches) return { matches: [] }
const retainer = new ItemRetainer<GrepMatch>({ kind: 'head', maxItems: caps.maxMatches })
const all: GrepMatch[] = []
for (const raw of parseGrepMatches(run.stdout)) {
const match: GrepMatch = {
path: toWorkdirRelative(raw.path, run.workdir),
lineNumber: raw.lineNumber,
line: previewLine(raw.line, caps.maxLineBytes),
line: raw.line,
}
all.push(match)
retainer.push(match)
}
const retained = retainer.finish()
// The spill file stores the FULL formatted match list (same grouped,
// per-line-previewed shape the model saw), so read offset/limit pages the
// same logical result; save only when the inline page omitted matches.
const spillRef = retained.truncated
? await trySaveFormattedResult(
ctx,
exec,
'grep-results.txt',
`Found ${all.length} ${matchNoun(all.length)}\n\n${formatGrepMatches(all)}`,
)
: undefined
return [{ type: 'text', text: formatGrepOutput(retained, spillRef) }]
return { matches: all }
},
presentCall: presentGrepCall,
}))
})
ctx.tools.register(tool)
ctx.on('tools/post-execute', async (exec, result, next) => {
const decision = await next()
const value = acceptedSurfaceValue(ctx, tool, exec, result, decision) as { matches: GrepMatch[] } | undefined
if (value === undefined) return decision
const matches = value.matches
if (matches.length <= caps.maxMatches) return decision
const spillRef = await trySaveFormattedResult(
ctx,
exec,
'grep-results.txt',
`Found ${matches.length} ${matchNoun(matches.length)}\n\n${formatGrepMatches(previewGrepMatches(matches, caps.maxLineBytes))}`,
)
return {
kind: 'accept',
content: [{
type: 'text',
text: renderGrepMatches(matches, caps.maxMatches, caps.maxLineBytes, spillRef),
}],
...decision.additionalContexts !== undefined ? { additionalContexts: decision.additionalContexts } : {},
}
})
}

View File

@@ -0,0 +1,27 @@
/** Shared surface-only post-policy selection for search result spill. @module dsh-tool-fs-search/surface */
import type { Context } from 'cordis'
import type { JsonValue, PostToolDecision, ToolDefinition, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
/**
* Return the accepted canonical value only when this tool still owns a direct
* successful surface call and no downstream policy replaced either projection.
* @param ctx - the tool plugin context used to resolve the live scoped owner.
* @param tool - the exact registered definition whose value may be projected.
* @param exec - the completed execution identity.
* @param result - the canonical result before post-policy decisions are applied.
* @param decision - the composed downstream post-policy decision.
* @returns the canonical value to project, or `undefined` when spill must defer.
*/
export function acceptedSurfaceValue(
ctx: Context,
tool: ToolDefinition,
exec: ToolExecution,
result: ToolExecutionResult,
decision: PostToolDecision,
): JsonValue | undefined {
if (decision.kind !== 'accept' || decision.content !== undefined || Object.hasOwn(decision, 'value')
|| exec.parent !== undefined || exec.name !== tool.name || result.isError
|| ctx.tools.get(exec.name, exec.agent) !== tool) return undefined
return result.value
}

View File

@@ -99,7 +99,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
it('classifies an invalid glob as SEARCH_INVALID_PATTERN', async () => {
const result = await call('glob', { pattern: '[' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_INVALID_PATTERN' })
expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_INVALID_PATTERN' } })
})
})
@@ -142,13 +142,13 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
it('classifies a real rg regex error as SEARCH_INVALID_PATTERN', async () => {
const result = await call('grep', { pattern: '(unclosed' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'SEARCH_INVALID_PATTERN' })
expect(result.error).toMatchObject({ info: { code: 'SEARCH_INVALID_PATTERN' } })
})
it('classifies a missing target as SEARCH_FAILED', async () => {
const result = await call('grep', { pattern: 'x', path: 'no-such-dir' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' })
expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } })
})
})
@@ -179,14 +179,14 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
signal: controller.signal,
})
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH })
expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } })
})
it('an unusable session cwd (spawn failure) is SEARCH_FAILED', async () => {
const gone = join(dir, 'deleted-session-dir')
const result = await call('glob', { pattern: '*' }, { session: { header: { id: 'session-int', cwd: gone } } })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_FAILED' })
expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } })
expect(text(result)).toContain('could not start')
})
})

View File

@@ -15,7 +15,7 @@ import { Context } from 'cordis'
import { join } from 'node:path'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH, type ToolExecutionToken } from '@deepseek-ai/dsh-tools'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill'
@@ -148,7 +148,12 @@ async function expectSetupRejects(options: SetupOptions, message: RegExp): Promi
const agent = (cwd?: string) => ({ session: { header: { id: 'session-1', ...cwd !== undefined ? { cwd } : {} } } })
let callCounter = 0
function call(ctx: Context, name: string, args: unknown, options: { agent?: object; signal?: AbortSignal } = {}) {
function call(
ctx: Context,
name: string,
args: unknown,
options: { agent?: object; signal?: AbortSignal; parent?: ToolExecutionToken } = {},
) {
return ctx.tools.execute({
signal: testToolSignal,
callId: CallId(`call-${++callCounter}`),
@@ -156,6 +161,7 @@ function call(ctx: Context, name: string, args: unknown, options: { agent?: obje
arguments: args,
...options.agent ? { agent: options.agent as never } : {},
...options.signal ? { signal: options.signal } : {},
...options.parent ? { parent: options.parent } : {},
})
}
@@ -320,7 +326,7 @@ describe('workdir derivation and signal forwarding', () => {
bash.handler = () => runResult('', { timedOut: true, timeoutMs: 1234, exitCode: null, signal: 'SIGTERM' })
const result = await call(ctx, 'glob', { pattern: '*' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'SEARCH_ABORTED' })
expect(result.error).toMatchObject({ info: { code: 'SEARCH_ABORTED' } })
expect(text(result)).toContain('timed out after 1234ms')
})
@@ -331,7 +337,7 @@ describe('workdir derivation and signal forwarding', () => {
bash.handler = () => { throw new Error('aborted before spawn') }
const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH })
expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } })
expect(bash.specs).toHaveLength(0)
})
@@ -346,7 +352,7 @@ describe('workdir derivation and signal forwarding', () => {
const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' })
expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_ABORTED' } })
expect(text(result)).toContain('aborted before completion')
})
@@ -357,7 +363,7 @@ describe('workdir derivation and signal forwarding', () => {
const result = await call(ctx, 'glob', { pattern: '*' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' })
expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_ABORTED' } })
expect(text(result)).toContain('aborted before completion')
})
@@ -367,7 +373,7 @@ describe('workdir derivation and signal forwarding', () => {
bash.handler = () => { throw new Error('spawn bash ENOENT') }
const result = await call(ctx, 'glob', { pattern: '*' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_FAILED' })
expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } })
expect(text(result)).toContain('could not start')
})
})
@@ -388,7 +394,7 @@ describe('exit semantics and failure classification', () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: regex parse error:\n (\nerror: unclosed group', truncated: false } })
const result = await call(ctx, 'grep', { pattern: '(' })
expect(result.error).toMatchObject({ code: 'SEARCH_INVALID_PATTERN' })
expect(result.error).toMatchObject({ info: { code: 'SEARCH_INVALID_PATTERN' } })
expect(text(result)).toContain('regex parse error')
})
@@ -396,14 +402,14 @@ describe('exit semantics and failure classification', () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: error parsing glob \'[\': unclosed character class', truncated: false } })
const result = await call(ctx, 'glob', { pattern: '[' })
expect(result.error).toMatchObject({ code: 'SEARCH_INVALID_PATTERN' })
expect(result.error).toMatchObject({ info: { code: 'SEARCH_INVALID_PATTERN' } })
})
it('a missing rg binary classifies as SEARCH_FAILED naming ripgrep', async () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('', { exitCode: 127, stderr: { text: 'bash: line 1: rg: command not found', truncated: false } })
const result = await call(ctx, 'glob', { pattern: '*' })
expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' })
expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } })
expect(text(result)).toContain('requires ripgrep (rg)')
// The same classification holds from either evidence alone: the 127 exit
// with silent stderr, or a shell's command-not-found text on another exit.
@@ -417,7 +423,7 @@ describe('exit semantics and failure classification', () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: missing.dir: IO error: no such file or directory', truncated: false } })
const result = await call(ctx, 'grep', { pattern: 'x', path: 'missing.dir' })
expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' })
expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } })
expect(text(result)).toContain('IO error')
})
@@ -425,7 +431,7 @@ describe('exit semantics and failure classification', () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('', { exitCode: 3 })
const result = await call(ctx, 'glob', { pattern: '*' })
expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' })
expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } })
expect(text(result)).toContain('exit 3')
})
@@ -443,7 +449,7 @@ describe('exit semantics and failure classification', () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('', { exitCode: null, signal: 'SIGKILL' })
const result = await call(ctx, 'grep', { pattern: 'x' })
expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' })
expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } })
expect(text(result)).toContain('SIGKILL')
})
@@ -451,7 +457,7 @@ describe('exit semantics and failure classification', () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('', { exitCode: null, signal: null })
const result = await call(ctx, 'glob', { pattern: '*' })
expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' })
expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } })
})
})
@@ -469,7 +475,7 @@ describe('raw output acquisition', () => {
const { ctx, bash } = await setup({ config: { rawOutputMaxBytes: 16 } })
bash.handler = () => runResult('', { stdout: { text: 'x', truncated: true, spillPath: '/does/not/get-read' } })
const result = await call(ctx, 'glob', { pattern: '*' })
expect(result.error).toMatchObject({ code: 'SEARCH_RAW_OUTPUT_OVERFLOW' })
expect(result.error).toMatchObject({ info: { code: 'SEARCH_RAW_OUTPUT_OVERFLOW' } })
expect(text(result)).toContain('narrow pattern, path, or include')
})
@@ -480,7 +486,7 @@ describe('raw output acquisition', () => {
const { ctx, bash } = await setup({ config: { rawOutputMaxBytes: 16 } })
bash.handler = () => runResult(`${'x'.repeat(64)}\n`)
const result = await call(ctx, 'grep', { pattern: 'x' })
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_RAW_OUTPUT_OVERFLOW' })
expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_RAW_OUTPUT_OVERFLOW' } })
expect(text(result)).toContain('narrow pattern, path, or include')
})
@@ -488,7 +494,7 @@ describe('raw output acquisition', () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('', { stdout: { text: 'partial', truncated: true } })
const result = await call(ctx, 'grep', { pattern: 'x' })
expect(result.error).toMatchObject({ code: 'SEARCH_RAW_OUTPUT_OVERFLOW' })
expect(result.error).toMatchObject({ info: { code: 'SEARCH_RAW_OUTPUT_OVERFLOW' } })
})
})
@@ -497,6 +503,8 @@ describe('glob results', () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('/sessions/s1/src/a.ts\n/elsewhere/b.ts\nrel/c.ts\n')
const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/sessions/s1') })
if (result.isError) throw new Error('expected glob success')
expect(result.value).toEqual({ paths: [join('src', 'a.ts'), '/elsewhere/b.ts', 'rel/c.ts'] })
expect(text(result)).toBe(`${join('src', 'a.ts')}\n/elsewhere/b.ts\nrel/c.ts`)
})
@@ -516,9 +524,15 @@ describe('glob results', () => {
it('caps at globMaxResults and saves the FULL sorted list through spillStore', async () => {
const { ctx, bash, spill } = await setup({ config: { globMaxResults: 2 }, spill: true })
ctx.on('tools/post-execute', async () => ({
kind: 'accept',
additionalContexts: [{ content: [{ type: 'text', text: 'glob context' }], source: { kind: 'plugin', plugin: 'test' } }],
}))
bash.handler = () => runResult('a.ts\nb.ts\nc.ts\nd.ts\n')
const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected glob success')
expect(result.value).toEqual({ paths: ['a.ts', 'b.ts', 'c.ts', 'd.ts'] })
expect(text(result)).toBe('a.ts\nb.ts\n\n(Showing 2 of 4 paths. Full sorted result stored at: /spill/glob-results.txt. Use the fake retrieval hint.)')
expect(spill?.saves).toHaveLength(1)
expect(spill?.saves[0]).toMatchObject({
@@ -528,6 +542,7 @@ describe('glob results', () => {
content: 'a.ts\nb.ts\nc.ts\nd.ts',
})
expect(spill?.saves[0]?.source.callId).toBeDefined()
expect(result.additionalContexts?.[0]?.content).toEqual([{ type: 'text', text: 'glob context' }])
})
it('does not create a spill file when the result fits inline', async () => {
@@ -538,6 +553,36 @@ describe('glob results', () => {
expect(spill?.saves).toHaveLength(0)
})
it('preserves a downstream canonical value replacement instead of spilling the old value', async () => {
const { ctx, bash, spill } = await setup({ config: { globMaxResults: 1 }, spill: true })
ctx.on('tools/post-execute', async () => ({
kind: 'accept' as const,
value: { paths: ['replacement-a.ts', 'replacement-b.ts'] },
}))
bash.handler = () => runResult('old-a.ts\nold-b.ts\n')
const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') })
if (result.isError) throw new Error('expected glob replacement success')
expect(result.value).toEqual({ paths: ['replacement-a.ts', 'replacement-b.ts'] })
expect(text(result)).toContain('replacement-a.ts')
expect(text(result)).not.toContain('old-a.ts')
expect(spill?.saves).toHaveLength(0)
})
it('keeps the full nested Code value without creating a surface spill', async () => {
const { ctx, bash, spill } = await setup({ config: { globMaxResults: 2 }, spill: true })
bash.handler = () => runResult('a.ts\nb.ts\nc.ts\nd.ts\n')
const result = await call(ctx, 'glob', { pattern: '*.ts' }, {
agent: agent('/w'),
parent: Symbol('run_code') as ToolExecutionToken,
})
if (result.isError) throw new Error('expected glob success')
expect(result.value).toEqual({ paths: ['a.ts', 'b.ts', 'c.ts', 'd.ts'] })
expect(text(result)).toBe('a.ts\nb.ts\n\n(Showing 2 of 4 paths. The complete result could not be saved; narrow pattern or path to see more.)')
expect(spill?.saves).toHaveLength(0)
})
it.each([
['no spill backend loaded', { fail: false, spill: false, ownerless: false }],
['saveText fails', { fail: true, spill: true, ownerless: false }],
@@ -566,6 +611,14 @@ describe('grep results', () => {
].join('\n'))
const result = await call(ctx, 'grep', { pattern: 'const' })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected grep success')
expect(result.value).toEqual({
matches: [
{ path: 'a.ts', lineNumber: 3, line: 'const x = 1' },
{ path: 'a.ts', lineNumber: 9, line: 'const y = 2' },
{ path: 'b.ts', lineNumber: 1, line: 'const z = 3' },
],
})
expect(text(result)).toBe('Found 3 matches\n\na.ts\nLine 3: const x = 1\nLine 9: const y = 2\n\nb.ts\nLine 1: const z = 3')
})
@@ -588,6 +641,8 @@ describe('grep results', () => {
// Use a multibyte straddle instead: 'aé' repeated — cut at 7 bytes: a(1)é(2)a(1)é(2)=6 +a(1)=7 → next é straddles: trimmed.
bash.handler = () => runResult(`${matchLine('a.txt', 1, 'aéaéaéaé')}\n`)
const result = await call(ctx, 'grep', { pattern: 'a' })
if (result.isError) throw new Error('expected grep success')
expect(result.value).toEqual({ matches: [{ path: 'a.txt', lineNumber: 1, line: 'aéaéaéaé' }] })
expect(text(result)).toContain('Line 1: aéaéa (line truncated)')
})
@@ -605,6 +660,10 @@ describe('grep results', () => {
it('caps at grepMaxMatches and spills the full formatted match list', async () => {
const { ctx, bash, spill } = await setup({ config: { grepMaxMatches: 2 }, spill: true })
ctx.on('tools/post-execute', async () => ({
kind: 'accept',
additionalContexts: [{ content: [{ type: 'text', text: 'grep context' }], source: { kind: 'plugin', plugin: 'test' } }],
}))
bash.handler = () => runResult([
matchLine('a.ts', 1, 'one'),
matchLine('a.ts', 2, 'two'),
@@ -612,12 +671,66 @@ describe('grep results', () => {
'',
].join('\n'))
const result = await call(ctx, 'grep', { pattern: 'e' }, { agent: agent('/w') })
if (result.isError) throw new Error('expected grep success')
expect(result.value).toEqual({
matches: [
{ path: 'a.ts', lineNumber: 1, line: 'one' },
{ path: 'a.ts', lineNumber: 2, line: 'two' },
{ path: 'b.ts', lineNumber: 3, line: 'three' },
],
})
expect(text(result)).toBe('Found 2 of 3 matches\n\na.ts\nLine 1: one\nLine 2: two\n\n(Full grep result stored at: /spill/grep-results.txt. Use the fake retrieval hint.)')
expect(spill?.saves[0]).toMatchObject({
source: { toolName: 'grep', label: 'result' },
suggestedName: 'grep-results.txt',
content: 'Found 3 matches\n\na.ts\nLine 1: one\nLine 2: two\n\nb.ts\nLine 3: three',
})
expect(result.additionalContexts?.[0]?.content).toEqual([{ type: 'text', text: 'grep context' }])
})
it('preserves a downstream canonical value replacement instead of spilling the old matches', async () => {
const { ctx, bash, spill } = await setup({ config: { grepMaxMatches: 1 }, spill: true })
ctx.on('tools/post-execute', async () => ({
kind: 'accept' as const,
value: {
matches: [
{ path: 'replacement.ts', lineNumber: 7, line: 'first' },
{ path: 'replacement.ts', lineNumber: 8, line: 'second' },
],
},
}))
bash.handler = () => runResult(`${matchLine('old.ts', 1, 'old')}\n`)
const result = await call(ctx, 'grep', { pattern: 'old' }, { agent: agent('/w') })
if (result.isError) throw new Error('expected grep replacement success')
expect(result.value).toEqual({
matches: [
{ path: 'replacement.ts', lineNumber: 7, line: 'first' },
{ path: 'replacement.ts', lineNumber: 8, line: 'second' },
],
})
expect(text(result)).toContain('replacement.ts')
expect(text(result)).not.toContain('old.ts')
expect(spill?.saves).toHaveLength(0)
})
it('keeps every nested Code match in the value without creating a surface spill', async () => {
const { ctx, bash, spill } = await setup({ config: { grepMaxMatches: 1 }, spill: true })
bash.handler = () => runResult(`${matchLine('a.ts', 1, 'one')}\n${matchLine('b.ts', 2, 'two')}\n`)
const result = await call(ctx, 'grep', { pattern: 'o' }, {
agent: agent('/w'),
parent: Symbol('run_code') as ToolExecutionToken,
})
if (result.isError) throw new Error('expected grep success')
expect(result.value).toEqual({
matches: [
{ path: 'a.ts', lineNumber: 1, line: 'one' },
{ path: 'b.ts', lineNumber: 2, line: 'two' },
],
})
expect(text(result)).toBe('Found 1 of 2 matches\n\na.ts\nLine 1: one\n\n(The complete result could not be saved; narrow pattern, path, or include to see more.)')
expect(spill?.saves).toHaveLength(0)
})
it('reports the unsaved remainder when capped with no spill backend', async () => {
@@ -660,7 +773,7 @@ describe('rg --json transport failures (SEARCH_FAILED)', () => {
bash.handler = () => runResult(`${line}\n`)
const result = await call(ctx, 'grep', { pattern: 'x' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_FAILED' })
expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } })
})
})

View File

@@ -32,6 +32,8 @@ All keys are optional; the defaults are the shipped read caps.
Field names are snake_case to match Claude Code and existing harness tool schemas.
Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. Native renderers preserve the line-numbered read and mutation acknowledgements below. Write/edit derive replayable diff-card metadata from these values; the values themselves are execution-local and are not added to `tool/result`.
## The tool is the executor; policy is an event gate
The tools do **not** inject a policy service or inspect any cache. Each tool resolves the path via `ctx.fs.resolve(path, { cwd, signal })` — passing the calling agent's session cwd (`exec.agent.session.header.cwd`) so a relative path resolves against the session's workspace, matching `dsh-tool-bash`, and forwarding tool cancellation through resolution (see [the per-session cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md)) — then:

View File

@@ -8,10 +8,9 @@
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts'
import { computeHunkDiffs, diffsFromMeta } from './diff.ts'
import { sessionResolveOptions } from './session-cwd.ts'
import type { FsSandboxSurface } from './sandbox.ts'
@@ -90,7 +89,26 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void {
replace_all: { type: 'boolean', description: 'Replace all matches. Defaults to false; when false, old_string must appear exactly once.' },
...sandbox.escalationModes.length > 0 ? sandbox.schemaFields() : {},
},
async execute(args: EditToolArgs, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> {
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
path: { type: 'string', required: true },
before: { type: 'string', required: true },
after: { type: 'string', required: true },
},
},
render: (args, value) => [{
type: 'text',
text: formatEditOutput(value.path, args.replace_all ?? false),
}],
presentationMeta: (args, value) => ({
diffs: computeHunkDiffs(args.file_path, value.before, value.after)
.map(({ path, oldText, newText }) => ({ path, oldText, newText })),
}),
},
async execute(args: EditToolArgs, exec) {
const input = parseEditArgs(args)
// Resolve the per-call sandbox policy (approved mode > session override
// > backend default, plus the session cwd root) BEFORE anything executes.
@@ -115,11 +133,10 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void {
}
// Record the observed version (a no-op when no policy plugin listens).
ctx.emit('fs/observed', target, outcome.version, exec)
// An edit necessarily changes content, so result metadata carries at least one applied hunk.
const diffs = computeHunkDiffs(input.filePath, outcome.before, outcome.after)
return {
content: [{ type: 'text', text: formatEditOutput(target.displayPath, input.replaceAll) }],
meta: { diffs },
path: target.displayPath,
before: outcome.before,
after: outcome.after,
}
},
// Pure display: a diff card of the literal replacement (old_string → new_string), derived

View File

@@ -37,9 +37,9 @@ export interface FileTextLine {
export interface WindowResult {
/** Returned lines, already numbered. */
lines: FileTextLine[]
/** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */
/** Exact total line count in the file. */
totalLines: number
/** Whether selected output hit the byte cap before EOF or the requested limit. */
/** Whether selected output hit the byte cap. */
truncatedByBytes: boolean
}
@@ -49,9 +49,9 @@ export interface FileReadOutcome {
offset: number
/** Returned lines, already numbered. */
lines: FileTextLine[]
/** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */
/** Exact total line count in the file. */
totalLines: number
/** Whether selected output hit the byte cap before EOF or the requested limit. */
/** Whether selected output hit the byte cap. */
truncatedByBytes?: true
}
@@ -60,11 +60,10 @@ interface WindowAccumulator {
totalLines: number
outputBytes: number
truncatedByBytes: boolean
done: boolean
}
function newAccumulator(): WindowAccumulator {
return { lines: [], totalLines: 0, outputBytes: 0, truncatedByBytes: false, done: false }
return { lines: [], totalLines: 0, outputBytes: 0, truncatedByBytes: false }
}
function truncateLine(line: string, maxLineLength: number): string {
@@ -77,13 +76,12 @@ function lineByteSize(line: string, currentLineCount: number): number {
function consumeLine(acc: WindowAccumulator, rawLine: string, request: ReadWindow): void {
acc.totalLines += 1
if (acc.totalLines < request.offset || acc.lines.length >= request.limit) return
if (acc.truncatedByBytes || acc.totalLines < request.offset || acc.lines.length >= request.limit) return
const text = truncateLine(rawLine, request.maxLineLength)
const bytes = lineByteSize(text, acc.lines.length)
if (acc.outputBytes + bytes > request.maxBytes) {
acc.truncatedByBytes = true
acc.done = true
return
}
acc.outputBytes += bytes
@@ -102,8 +100,9 @@ function finish(acc: WindowAccumulator, request: ReadWindow, displayPath: string
}
/**
* Build one window from streamed or whole-file chunks, enforcing line and byte caps and throwing
* `FS_NOT_FOUND` when the requested offset is past EOF.
* Build one window from streamed or whole-file chunks, enforcing line and byte caps while still
* scanning to an exact total line count, and throwing `FS_NOT_FOUND` when the requested offset is
* past EOF.
* @param chunks - decoded text chunks in file order; chunk boundaries carry no meaning.
* @param request - the resolved window; the caller has already applied its defaults and caps.
* @param displayPath - the caller-facing path used in the offset-out-of-range error.
@@ -137,7 +136,6 @@ export async function buildWindow(
appendToLineBuffer(chunk.slice(startPos, newlinePos))
flushLine()
startPos = newlinePos + 1
if (acc.done) return finish(acc, request, displayPath)
}
appendToLineBuffer(chunk.slice(startPos))
}

View File

@@ -7,12 +7,10 @@
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { FsError } from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { buildWindow, formatReadOutput } from './read-render.ts'
import type { FileReadOutcome } from './read-render.ts'
import { sessionResolveOptions } from './session-cwd.ts'
/** Default and maximum number of lines returned by one `read` call (the `readLimit` config). */
@@ -84,9 +82,46 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
offset: { type: 'number', description: '1-based first line to return. Defaults to 1.' },
limit: { type: 'number', description: `Maximum number of lines to return. Defaults to ${caps.limit}.` },
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
path: { type: 'string', required: true },
offset: { type: 'integer', required: true },
lines: {
type: 'array',
required: true,
items: {
type: 'object',
additionalProperties: false,
properties: {
number: { type: 'integer', required: true },
text: { type: 'string', required: true },
},
},
},
totalLines: { type: 'integer', required: true },
},
},
render: (args, value) => {
const input = parseReadArgs(args, caps.limit)
const endLine = value.lines.at(-1)?.number ?? Math.max(0, value.offset - 1)
const truncatedByBytes = value.lines.length < input.limit && endLine < value.totalLines
return [{
type: 'text',
text: formatReadOutput(value.path, {
offset: value.offset,
lines: value.lines,
totalLines: value.totalLines,
...truncatedByBytes ? { truncatedByBytes: true } : {},
}),
}]
},
},
// Observation races fail closed because guarded mutations re-check the version in-lock.
isConcurrencySafe: () => true,
async execute(args, exec): Promise<ContentBlock[]> {
async execute(args, exec) {
const input = parseReadArgs(args, caps.limit)
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath))
@@ -107,17 +142,17 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
target.displayPath,
)
const outcome: FileReadOutcome = {
const outcome = {
path: target.displayPath,
offset: input.offset,
lines: window.lines,
totalLines: window.totalLines,
...window.truncatedByBytes ? { truncatedByBytes: true } : {},
}
// Record the observed version (a no-op when no policy plugin listens). The
// read already succeeded; an fs/observed listener is contractually a
// synchronous, side-effect-only recorder.
ctx.emit('fs/observed', target, info.version, exec)
return [{ type: 'text', text: formatReadOutput(target.displayPath, outcome) }]
return outcome
},
// Pure display: a generic card titled by the file with the read window appended (`Read
// foo.txt (5 - 8)`), `read` kind (icon), and a follow-along location whose line is the

View File

@@ -8,11 +8,10 @@
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { FsWriteOutcome } from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts'
import { computeHunkDiffs, diffsFromMeta } from './diff.ts'
import { sessionResolveOptions } from './session-cwd.ts'
import type { FsSandboxSurface } from './sandbox.ts'
@@ -33,7 +32,7 @@ export function parseWriteArgs(args: { file_path: string; content: string }): {
* @param outcome - the write outcome; its `operation` selects the Created/Updated wording.
* @returns the model-facing confirmation envelope (no file content is echoed back).
*/
export function formatWriteOutput(displayPath: string, outcome: FsWriteOutcome): string {
export function formatWriteOutput(displayPath: string, outcome: Pick<FsWriteOutcome, 'operation'>): string {
const verb = outcome.operation === 'create' ? 'Created' : 'Updated'
return `<path>${displayPath}</path>
<type>file</type>
@@ -74,7 +73,32 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void {
content: { type: 'string', required: true, description: 'Full UTF-8 text content to write.' },
...sandbox.escalationModes.length > 0 ? sandbox.schemaFields() : {},
},
async execute(args: WriteToolArgs, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> {
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
path: { type: 'string', required: true },
operation: { type: 'string', required: true, enum: ['create', 'update'] },
before: {
required: true,
oneOf: [
{ type: 'string' },
{ type: 'null' },
],
},
after: { type: 'string', required: true },
},
},
render: (_args, value) => [{ type: 'text', text: formatWriteOutput(value.path, value) }],
presentationMeta: (args, value) => ({
diffs: value.before === null
? []
: computeHunkDiffs(args.file_path, value.before, value.after)
.map(({ path, oldText, newText }) => ({ path, oldText, newText })),
}),
},
async execute(args: WriteToolArgs, exec) {
const input = parseWriteArgs(args)
// Resolve the per-call sandbox policy (approved mode > session override
// > backend default, plus the session cwd root) BEFORE anything executes;
@@ -94,12 +118,11 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void {
}
// Record the observed version (a no-op when no policy plugin listens).
ctx.emit('fs/observed', target, outcome.version, exec)
// Overwrites carry applied hunks. Creates have no prior text, so result presentation uses
// the args-derived whole-file diff instead.
const diffs = outcome.before !== null ? computeHunkDiffs(input.filePath, outcome.before, outcome.after) : []
return {
content: [{ type: 'text', text: formatWriteOutput(target.displayPath, outcome) }],
...diffs.length > 0 ? { meta: { diffs } } : {},
path: target.displayPath,
operation: outcome.operation,
before: outcome.before,
after: outcome.after,
}
},
// Pure display: a diff card (an editor renders write as a new-file / full- replace diff).

View File

@@ -70,7 +70,7 @@ describe('default deployment (with dsh-fs-policy)', () => {
await writeFile(join(dir, 'a.txt'), 'original')
const result = await call('write', { file_path: 'a.txt', content: 'clobber' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } })
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('original')
})
@@ -88,7 +88,7 @@ describe('default deployment (with dsh-fs-policy)', () => {
await writeFile(join(dir, 'a.txt'), 'changed-externally') // out-of-band change
const result = await call('write', { file_path: 'a.txt', content: 'replaced' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' })
expect(result.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
})
})
@@ -105,7 +105,7 @@ describe('default deployment (with dsh-fs-policy)', () => {
await writeFile(join(dir, 'bin'), Buffer.from([0x00, 0x01, 0x02]))
const result = await call('read', { file_path: 'bin' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_NOT_TEXT' })
expect(result.error).toMatchObject({ info: { code: 'FS_NOT_TEXT' } })
})
it('paginates a multi-line file with offset/limit', async () => {
@@ -130,7 +130,7 @@ describe('default deployment (with dsh-fs-policy)', () => {
await writeFile(join(dir, 'a.txt'), 'hello world')
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } })
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world')
})
@@ -154,7 +154,7 @@ describe('default deployment (with dsh-fs-policy)', () => {
await writeFile(join(dir, 'a.txt'), 'goodbye') // out-of-band change removes 'world'
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' })
expect(result.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
})
it('rejects an ambiguous match without replace_all', async () => {
@@ -162,7 +162,7 @@ describe('default deployment (with dsh-fs-policy)', () => {
await call('read', { file_path: 'a.txt' })
const result = await call('edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_AMBIGUOUS_EDIT' })
expect(result.error).toMatchObject({ info: { code: 'FS_AMBIGUOUS_EDIT' } })
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('a a a')
})
@@ -190,7 +190,7 @@ describe('default deployment (with dsh-fs-policy)', () => {
// The model-facing edit still rejects: the read did not emit fs/observed.
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } })
})
})
@@ -263,14 +263,14 @@ describe('bare provider (no dsh-fs-policy)', () => {
it('edit of a MISSING target reports FS_STALE_VERSION even on the unguarded path', async () => {
const result = await call('edit', { file_path: 'missing.txt', old_string: 'a', new_string: 'b' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' })
expect(result.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
})
it('edit still enforces literal-match codes (FS_EDIT_NOT_FOUND), unrelated to freshness', async () => {
await writeFile(join(dir, 'a.txt'), 'hello world')
const result = await call('edit', { file_path: 'a.txt', old_string: 'absent', new_string: 'x' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_EDIT_NOT_FOUND' })
expect(result.error).toMatchObject({ info: { code: 'FS_EDIT_NOT_FOUND' } })
})
it('neither write nor edit stats in the tool on the bare path', async () => {
@@ -354,11 +354,11 @@ describe('signal, concurrency, and the fs/observed contract', () => {
await writeFile(join(dir, 'a.txt'), 'hello')
const read = await callSig(AbortSignal.abort(), 'read', { file_path: 'a.txt' })
expect(read.isError).toBe(true)
expect(read.error).toMatchObject({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH })
expect(read.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } })
const write = await callSig(AbortSignal.abort(), 'write', { file_path: 'new.txt', content: 'x' })
expect(write.isError).toBe(true)
expect(write.error).toMatchObject({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH })
expect(write.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } })
await expect(readFile(join(dir, 'new.txt'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
// Read first (un-aborted, SAME session owner) so the edit clears the
@@ -366,7 +366,7 @@ describe('signal, concurrency, and the fs/observed contract', () => {
expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false)
const edit = await callSig(AbortSignal.abort(), 'edit', { file_path: 'a.txt', old_string: 'hello', new_string: 'bye' })
expect(edit.isError).toBe(true)
expect(edit.error).toMatchObject({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH })
expect(edit.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } })
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello') // unchanged
})
@@ -381,7 +381,7 @@ describe('signal, concurrency, and the fs/observed contract', () => {
])
const errors = [one, two].filter(r => r.isError)
expect(errors).toHaveLength(1)
expect(errors[0]?.error).toMatchObject({ code: 'FS_STALE_VERSION' })
expect(errors[0]?.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
// The world is consistent: exactly one edit landed.
const onDisk = await readFile(join(dir, 'a.txt'), 'utf8')
expect(onDisk === 'ONE value here' || onDisk === 'base TWO here').toBe(true)
@@ -410,7 +410,7 @@ describe('signal, concurrency, and the fs/observed contract', () => {
new_string: 'edited',
})
expect(edit.isError).toBe(true)
expect(edit.error).toMatchObject({ code: 'FS_STALE_VERSION' })
expect(edit.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('newer current content\n')
})

View File

@@ -86,6 +86,7 @@ describe('buildWindow', () => {
it('caps output at a custom maxBytes', async () => {
const result = await buildWindow(whole('aaaa\nbbbb\ncccc'), { offset: 1, limit: 10, maxLineLength: 2000, maxBytes: 9 }, 'f')
expect(result.lines.map(l => l.text)).toEqual(['aaaa', 'bbbb'])
expect(result.totalLines).toBe(3)
expect(result.truncatedByBytes).toBe(true)
})
})
@@ -105,6 +106,7 @@ describe('buildWindow', () => {
it('caps output bytes mid-stream', async () => {
const big = Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')
const result = await buildWindow(chunked(big, 512), READ_ALL, 'f')
expect(result.totalLines).toBe(2000)
expect(result.truncatedByBytes).toBe(true)
})

View File

@@ -195,6 +195,13 @@ describe('read tool', () => {
fs.files.set('key:a.txt', 'hello\nworld')
const result = await call(ctx, 'read', { file_path: 'a.txt' })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected read success')
expect(result.value).toEqual({
path: '/abs/a.txt',
offset: 1,
lines: [{ number: 1, text: 'hello' }, { number: 2, text: 'world' }],
totalLines: 2,
})
expect(text(result)).toBe(`<path>/abs/a.txt</path>
<type>file</type>
<content>
@@ -205,6 +212,15 @@ describe('read tool', () => {
</content>`)
})
it('returns an explicit empty canonical line window for an empty file', async () => {
const { ctx, fs } = await setup()
fs.files.set('key:empty.txt', '')
const result = await call(ctx, 'read', { file_path: 'empty.txt' })
if (result.isError) throw new Error('expected empty read success')
expect(result.value).toEqual({ path: '/abs/empty.txt', offset: 1, lines: [], totalLines: 0 })
expect(text(result)).toContain('(End of file - total 0 lines)')
})
it('rejects a non-positive offset via arg validation', async () => {
const { ctx } = await setup()
const result = await call(ctx, 'read', { file_path: 'a.txt', offset: 0 })
@@ -260,7 +276,7 @@ describe('read tool', () => {
const { ctx } = await setup()
const result = await call(ctx, 'read', { file_path: 'missing.txt' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_NOT_FOUND' })
expect(result.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } })
})
it('rejects a non-regular target', async () => {
@@ -269,7 +285,7 @@ describe('read tool', () => {
fs.stat = async () => ({ version: FsVersion('v1'), type: 'directory' })
const result = await call(ctx, 'read', { file_path: 'd' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
expect(result.error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } })
})
it('streams a large file (size at/above the cap) instead of reading whole', async () => {
@@ -335,6 +351,8 @@ describe('write tool', () => {
const { ctx, fs } = await setup()
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' }, { session: { header: {} } })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected write success')
expect(result.value).toEqual({ path: '/abs/a.txt', operation: 'create', before: null, after: 'hi' })
expect(text(result)).toContain('Created file')
expect(fs.writeIntents).toEqual([{ kind: 'createIfAbsent' }])
})
@@ -351,7 +369,7 @@ describe('write tool', () => {
fs.rejectWith = new FsError('blocked', 'FS_STALE_VERSION')
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ name: 'FsError', code: 'FS_STALE_VERSION' })
expect(result.error).toMatchObject({ info: { name: 'FsError', code: 'FS_STALE_VERSION' } })
})
})
@@ -362,6 +380,8 @@ describe('edit tool', () => {
fs.files.set('key:a.txt', 'a')
await call(ctx, 'read', { file_path: 'a.txt' }, { session })
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session })
if (result.isError) throw new Error('expected edit success')
expect(result.value).toEqual({ path: '/abs/a.txt', before: 'a', after: 'b' })
expect(text(result)).toBe('The file /abs/a.txt has been updated successfully.')
})
@@ -400,7 +420,7 @@ describe('edit tool', () => {
fs.files.set('key:a.txt', 'hello')
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session: { header: {} } })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } })
})
})
@@ -498,27 +518,27 @@ describe('result-time contextual diff (meta + presentResult)', () => {
expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }] })
})
it('write CREATE: no before-version → no meta, but presentResult still renders a whole-file diff card', async () => {
// A create has no prior content (no `meta`), yet the completed card must be a `diff` — an
it('write CREATE: an empty applied-diff projection still falls back to the whole-file diff card', async () => {
// A create has no prior content, yet the completed card must be a `diff` — an
// ACP tool_call_update.content REPLACES the call's content, so a non-diff result would
// clobber the pending new-file diff.
const { ctx } = await setup()
const session = { header: {} }
const result = await call(ctx, 'write', { file_path: 'new.txt', content: 'fresh\n' }, { session })
expect(result.isError).toBe(false)
expect(result.meta).toBeUndefined()
expect(result.meta).toEqual({ diffs: [] })
const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'new.txt', content: 'fresh\n' }, result)
expect(view).toEqual({ card: 'diff', title: 'Write new.txt', diffs: [{ path: 'new.txt', oldText: null, newText: 'fresh\n' }] })
})
it('write OVERWRITE with identical content: a before exists but yields no hunk → no meta, presentResult falls back to a whole-file diff', async () => {
it('write OVERWRITE with identical content: an empty applied-diff projection falls back to a whole-file diff', async () => {
const { ctx, fs } = await setup()
const session = { header: {} }
fs.files.set('key:a.txt', 'same\n')
await call(ctx, 'read', { file_path: 'a.txt' }, { session })
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'same\n' }, { session })
expect(result.isError).toBe(false)
expect(result.meta).toBeUndefined()
expect(result.meta).toEqual({ diffs: [] })
const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'same\n' }, result)
expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: null, newText: 'same\n' }] })
})
@@ -583,6 +603,9 @@ describe('read caps are plugin config', () => {
const { ctx, fs } = await setupWith({ readMaxBytes: 9 })
fs.files.set('key:a.txt', 'aaaa\nbbbb\ncccc')
const result = await call(ctx, 'read', { file_path: 'a.txt' })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected read success')
expect(result.value).toMatchObject({ totalLines: 3 })
expect(text(result)).toContain('Output capped.')
expect(text(result)).not.toContain('cccc')
})

View File

@@ -10,6 +10,8 @@ The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal
All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations.
All three canonical values match the compact JSON already rendered to Native callers: `{ goal: null }` or `{ goal: { id, revision, objective, phase, roundsStarted, maxGoalRounds, blockedReason? }, activation }`. Programmatic consumers therefore receive the same domain structure without parsing the rendered JSON.
An autonomous goal round that successfully reports `complete` or `blocked` contributes the existing terminal `agent/turn-stop` decision for that physical turn. Direct-human mutations never contribute this stop: the assistant may acknowledge the change and concurrent human steering remains available to the loop.
## Authority

View File

@@ -54,6 +54,62 @@ const GET_DESCRIPTION =
+ 'continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. '
+ 'Call this before updating a goal.'
/** Canonical goal-tool output, matching the existing compact Native JSON. */
type GoalToolValue =
| { goal: null }
| {
goal: {
id: string
revision: number
objective: string
phase: GoalView['phase']
roundsStarted: number
maxGoalRounds: number
blockedReason?: { code: string; message: string }
}
activation: GoalView['activation']
}
const GOAL_VALUE_SCHEMA = {
oneOf: [
{
type: 'object',
additionalProperties: false,
properties: {
goal: { type: 'null', required: true },
},
},
{
type: 'object',
additionalProperties: false,
properties: {
goal: {
type: 'object',
additionalProperties: false,
required: true,
properties: {
id: { type: 'string', required: true },
revision: { type: 'integer', required: true },
objective: { type: 'string', required: true },
phase: { type: 'string', required: true, enum: ['active', 'paused', 'blocked', 'complete'] },
roundsStarted: { type: 'integer', required: true },
maxGoalRounds: { type: 'integer', required: true },
blockedReason: {
type: 'object',
additionalProperties: false,
properties: {
code: { type: 'string', required: true },
message: { type: 'string', required: true },
},
},
},
},
activation: { type: 'string', required: true, enum: ['armed', 'disarmed'] },
},
},
],
} as const
/** Render policy guidance with its deployment-selected blocked threshold. */
function guidance(blockedAfter: number): string {
return 'Use goal tools for one long-running completion objective in the current session. '
@@ -89,9 +145,9 @@ function goalRef(goalId: string, revision: number): GoalRef {
}
/** Stable compact model result; activation is an observation, not replay state. */
function renderGoal(goal: GoalView | undefined): string {
if (goal === undefined) return JSON.stringify({ goal: null })
return JSON.stringify({
function goalValue(goal: GoalView | undefined): GoalToolValue {
if (goal === undefined) return { goal: null }
return {
goal: {
id: goal.id,
revision: goal.revision,
@@ -99,10 +155,18 @@ function renderGoal(goal: GoalView | undefined): string {
phase: goal.phase,
roundsStarted: goal.roundsStarted,
maxGoalRounds: goal.maxGoalRounds,
...goal.blockedReason === undefined ? {} : { blockedReason: goal.blockedReason },
...goal.blockedReason === undefined ? {} : {
blockedReason: { code: goal.blockedReason.code, message: goal.blockedReason.message },
},
},
activation: goal.activation,
})
}
}
/** Reusable canonical output declaration for all three goal controls. */
const GOAL_OUTPUT = {
schema: GOAL_VALUE_SCHEMA,
render: (_args: unknown, value: GoalToolValue) => [{ type: 'text' as const, text: JSON.stringify(value) }],
}
/** Generic, args-only pending presentation shared by the goal tools. */
@@ -144,12 +208,10 @@ export function apply(ctx: Context, config: Config): void {
name: 'get_goal',
description: GET_DESCRIPTION,
parameters: {},
output: GOAL_OUTPUT,
execute(_args, exec) {
const execution = goalToolExecution(ctx, exec)
return Promise.resolve([{
type: 'text',
text: renderGoal(ctx.goals.get(execution.agent)),
}])
return Promise.resolve(goalValue(ctx.goals.get(execution.agent)))
},
presentCall: () => present('Read current goal', 'read'),
}))
@@ -168,6 +230,7 @@ export function apply(ctx: Context, config: Config): void {
description: 'Optional positive safe-integer limit on automatic continuation rounds.',
},
},
output: GOAL_OUTPUT,
execute(args, exec) {
const execution = goalToolExecution(ctx, exec)
requireDirectHuman(ctx, execution)
@@ -176,7 +239,7 @@ export function apply(ctx: Context, config: Config): void {
...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds },
})
observeMutation(terminalTurns, execution, false)
return Promise.resolve([{ type: 'text', text: renderGoal(goal) }])
return Promise.resolve(goalValue(goal))
},
presentCall: args => present('Create goal', 'other', args.objective),
}))
@@ -203,6 +266,7 @@ export function apply(ctx: Context, config: Config): void {
description: 'Concrete blocking condition; required only with action blocked.',
},
},
output: GOAL_OUTPUT,
execute(args, exec) {
const execution = goalToolExecution(ctx, exec)
const ref = goalRef(args.goal_id, args.revision)
@@ -217,10 +281,7 @@ export function apply(ctx: Context, config: Config): void {
}
const goal = ctx.goals.edit(execution.agent, ref, replacements)
observeMutation(terminalTurns, execution, false)
return Promise.resolve([{
type: 'text',
text: renderGoal(goal),
}])
return Promise.resolve(goalValue(goal))
}
if (args.action === 'pause' || args.action === 'resume') {
requireDirectHuman(ctx, execution)
@@ -234,7 +295,7 @@ export function apply(ctx: Context, config: Config): void {
? ctx.goals.pause(execution.agent, ref)
: ctx.goals.resume(execution.agent, ref)
observeMutation(terminalTurns, execution, false)
return Promise.resolve([{ type: 'text', text: renderGoal(goal) }])
return Promise.resolve(goalValue(goal))
}
const authority = completionAuthority(ctx, execution)
if (args.objective !== undefined || args.max_goal_rounds !== undefined) {
@@ -265,7 +326,7 @@ export function apply(ctx: Context, config: Config): void {
message: args.blocked_reason as string,
})
observeMutation(terminalTurns, execution, authority.kind === 'goal-round')
return Promise.resolve([{ type: 'text', text: renderGoal(goal) }])
return Promise.resolve(goalValue(goal))
},
presentCall: args => present(
`${args.action === 'blocked' ? 'Mark' : args.action.charAt(0).toUpperCase() + args.action.slice(1)} goal`,

View File

@@ -98,9 +98,12 @@ async function execute(
/** Parse the compact JSON returned by a successful goal tool. */
function resultJson(result: ToolExecutionResult): Record<string, unknown> {
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected goal tool success')
const block = result.content[0]
if (block?.type !== 'text') throw new Error('expected text tool result')
return JSON.parse(block.text) as Record<string, unknown>
const parsed = JSON.parse(block.text) as Record<string, unknown>
expect(result.value).toEqual(parsed)
return parsed
}
/** Read the returned goal sub-object. */
@@ -196,7 +199,7 @@ describe('goal tool execution authority', () => {
it('rejects agentless, driverless, non-human, and live-child creation', async () => {
const { ctx, root } = await harness()
const agentless = await execute(ctx, 'get_goal', {})
expect(agentless.error?.code).toBe('GOAL_TOOL_AGENT_REQUIRED')
expect(agentless.error?.info?.code).toBe('GOAL_TOOL_AGENT_REQUIRED')
openTurn(root, { kind: 'user' })
const driverless = await ctx.tools.execute({
@@ -206,12 +209,12 @@ describe('goal tool execution authority', () => {
arguments: {},
agent: root.agent,
})
expect(driverless.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
expect(driverless.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
closeTurn(root, 1)
openTurn(root, { kind: 'plugin', plugin: 'test' })
const nonHuman = await execute(ctx, 'create_goal', { objective: 'forged' }, root.agent)
expect(nonHuman.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
expect(nonHuman.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
closeTurn(root, 2)
const child = stubAgent('goal-tool-child')
@@ -219,7 +222,7 @@ describe('goal tool execution authority', () => {
ctx.agents.announce(child.agent)
openTurn(child, { kind: 'user' })
const childResult = await execute(ctx, 'create_goal', { objective: 'child goal' }, child.agent)
expect(childResult.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
expect(childResult.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
})
it('rejects stale agent objects and agents outside running status through the executor', async () => {
@@ -227,11 +230,11 @@ describe('goal tool execution authority', () => {
openTurn(root, { kind: 'user' })
const stale = { ...root.agent }
const staleResult = await execute(ctx, 'get_goal', {}, stale, stale)
expect(staleResult.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
expect(staleResult.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
root.setStatus('idle')
const idleResult = await execute(ctx, 'get_goal', {}, root.agent)
expect(idleResult.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
expect(idleResult.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
})
it('treats a fork resumed as a runtime root as direct-human authority', async () => {
@@ -261,12 +264,12 @@ describe('goal tool execution authority', () => {
it('rejects calls before a turn and after its end boundary', async () => {
const { ctx, root } = await harness()
const before = await execute(ctx, 'get_goal', {}, root.agent)
expect(before.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
expect(before.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
const turn = openTurn(root, { kind: 'user' })
closeTurn(root, turn)
const after = await execute(ctx, 'get_goal', {}, root.agent)
expect(after.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
expect(after.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
})
it('rejects terminal reporting without human input or a current goal round', async () => {
@@ -275,11 +278,11 @@ describe('goal tool execution authority', () => {
const result = await execute(ctx, 'update_goal', {
goal_id: 'goal-missing', revision: 1, action: 'complete',
}, root.agent)
expect(result.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
expect(result.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
const malformed = await execute(ctx, 'update_goal', {
goal_id: 'goal-missing', revision: 1, action: 'pause', objective: 'probe',
}, root.agent)
expect(malformed.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
expect(malformed.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
})
it('accepts direct human steering in a goal-sourced root turn', async () => {
@@ -307,7 +310,7 @@ describe('goal tool execution authority', () => {
ctx.agents.register(other.agent)
openTurn(other, { kind: 'user' })
const result = await execute(ctx, 'get_goal', {}, other.agent, root.agent)
expect(result.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
expect(result.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
})
})
@@ -378,7 +381,7 @@ describe('goal tool state transitions', () => {
const { ctx, root } = await harness()
openTurn(root, { kind: 'user' })
const invalidCreate = await execute(ctx, 'create_goal', { objective: ' ' }, root.agent)
expect(invalidCreate.error?.code).toBe('GOAL_INVALID_OBJECTIVE')
expect(invalidCreate.error?.info?.code).toBe('GOAL_INVALID_OBJECTIVE')
const created = ctx.goals.create(root.agent, { objective: 'valid' })
const replacement = await execute(ctx, 'update_goal', {
goal_id: created.id,
@@ -386,26 +389,26 @@ describe('goal tool state transitions', () => {
action: 'pause',
objective: 'not valid for pause',
}, root.agent)
expect(replacement.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
expect(replacement.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
const terminalUpdate = await execute(ctx, 'update_goal', {
goal_id: created.id,
revision: created.revision,
action: 'complete',
max_goal_rounds: 2,
}, root.agent)
expect(terminalUpdate.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
expect(terminalUpdate.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
const blockedWithoutReason = await execute(ctx, 'update_goal', {
goal_id: created.id, revision: created.revision, action: 'blocked',
}, root.agent)
expect(blockedWithoutReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
expect(blockedWithoutReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
const blockedWithEmptyReason = await execute(ctx, 'update_goal', {
goal_id: created.id, revision: created.revision, action: 'blocked', blocked_reason: ' ',
}, root.agent)
expect(blockedWithEmptyReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
expect(blockedWithEmptyReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
const completeWithReason = await execute(ctx, 'update_goal', {
goal_id: created.id, revision: created.revision, action: 'complete', blocked_reason: 'Not a blocker.',
}, root.agent)
expect(completeWithReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
expect(completeWithReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
const editWithReason = await execute(ctx, 'update_goal', {
goal_id: created.id,
revision: created.revision,
@@ -413,11 +416,11 @@ describe('goal tool state transitions', () => {
objective: 'still valid',
blocked_reason: 'Not valid for edit.',
}, root.agent)
expect(editWithReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
expect(editWithReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
const malformedRef = await execute(ctx, 'update_goal', {
goal_id: '', revision: 0, action: 'edit', objective: 'x',
}, root.agent)
expect(malformedRef.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
expect(malformedRef.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
})
it('allows exact goal rounds to complete but not edit or pause', async () => {
@@ -429,7 +432,7 @@ describe('goal tool state transitions', () => {
const edit = await execute(ctx, 'update_goal', {
goal_id: created.id, revision: created.revision, action: 'edit', objective: 'forbidden',
}, root.agent)
expect(edit.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
expect(edit.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
const complete = await execute(ctx, 'update_goal', {
goal_id: created.id, revision: created.revision, action: 'complete',
}, root.agent)
@@ -451,7 +454,7 @@ describe('goal tool state transitions', () => {
action: 'blocked',
blocked_reason: 'The required credential is still unavailable.',
}, root.agent)
expect(result.error?.code).toBe('GOAL_TOOL_BLOCK_THRESHOLD')
expect(result.error?.info?.code).toBe('GOAL_TOOL_BLOCK_THRESHOLD')
closeTurn(root, turn)
}
openTurn(root, { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 3 })

View File

@@ -213,8 +213,7 @@ export function apply(ctx: Context, config: Config): void {
return { kind: 'block', feedback: downstream.feedback, additionalContexts: prependContext(reminder, downstream.additionalContexts) }
}
return {
kind: 'accept',
...downstream.content !== undefined ? { content: downstream.content } : {},
...downstream,
additionalContexts: prependContext(reminder, downstream.additionalContexts),
}
})

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
@@ -26,8 +26,8 @@ async function harness(config: Config = {}): Promise<Context> {
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(RepeatToolGuard, config)
ctx.tools.register(defineTool({ name: 'probe', description: 'p', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineTool({ name: 'other', description: 'o', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'probe', description: 'p', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'other', description: 'o', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
return ctx
}
@@ -334,11 +334,11 @@ describe('fold onto the downstream decision', () => {
expect(results[1]!.data.content).toEqual([{ type: 'text', text: 'nope' }])
})
it('preserves a downstream accept content replacement while folding', async () => {
it('preserves a downstream canonical value replacement while folding', async () => {
const ctx = await harness({ thresholds: [2] })
ctx.on('tools/post-execute', async () => ({
kind: 'accept' as const,
content: [{ type: 'text' as const, text: 'replaced' }],
value: [{ type: 'text' as const, text: 'replaced' }],
}))
const adapter = new MockAdapter([
toolCallResponse('c1', 'probe', { q: 1 }),

View File

@@ -253,8 +253,7 @@ export function apply(ctx: Context, config: Config): void {
return { ...downstream, additionalContexts: prependContext(context, downstream.additionalContexts) }
}
return {
kind: 'accept',
...downstream.content !== undefined ? { content: downstream.content } : {},
...downstream,
additionalContexts: prependContext(context, downstream.additionalContexts),
}
})

View File

@@ -5,7 +5,7 @@ import { join } from 'node:path'
import { Context, type Fiber } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
@@ -143,7 +143,7 @@ describe('hooks-claude bridge — PreToolUse', () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'danger', {}), textResponse('done')])
const ctx = await harness(dir, adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'danger', description: 'd', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'danger', description: 'd', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'use danger' }])
await waitForIdle(ctx, agent)
@@ -166,7 +166,7 @@ describe('hooks-claude bridge — PreToolUse', () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'safe', {}), textResponse('done')])
const ctx = await harness(dir, adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'safe', description: 's', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ran ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'safe', description: 's', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ran ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'use safe' }])
await waitForIdle(ctx, agent)
@@ -188,7 +188,7 @@ describe('hooks-claude bridge — PostToolUse', () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(dir, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'raw output' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'raw output' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -209,7 +209,7 @@ describe('hooks-claude bridge — PostToolUse', () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(dir, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -233,7 +233,7 @@ describe('hooks-claude bridge — PostToolUse', () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(dir, adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)

View File

@@ -5,7 +5,7 @@ import { join } from 'node:path'
import { Context } from 'cordis'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
@@ -72,7 +72,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'capture.sh', `#!/usr/bin/env bash\ncat > "${cap}"\n`) }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} })
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -102,7 +102,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter, { pluginRoot: d, projectDir: d })
ctx.logger.warn = warn as never
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -118,7 +118,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const ctx = await harness(path, adapter)
ctx.logger.warn = warn as never
let sawArgs: unknown
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: { command: { type: 'string' } }, async execute(args) { sawArgs = args; return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: { command: { type: 'string' } }, async execute(args) { sawArgs = args; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -149,7 +149,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const ctx = await harness(path, new MockAdapter([]))
let ran = false
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
// Call execute() directly with NO agent — the bridge's no-agent/no-turn path.
const { CallId } = await import('@deepseek-ai/dsh-llm')
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {} })
@@ -164,7 +164,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -189,7 +189,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 })
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -269,7 +269,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'x' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'x' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -283,7 +283,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -327,7 +327,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -342,7 +342,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -382,7 +382,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -397,7 +397,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -416,7 +416,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -433,7 +433,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -453,7 +453,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -536,16 +536,16 @@ export function defineCoverageCases(group: CoverageGroup): void {
expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' })
})
it('folds the bridge PostToolUse context onto a downstream ACCEPT that replaces content', async () => {
it('folds the bridge PostToolUse context onto a downstream canonical value replacement', async () => {
// The bridge hook adds context; a later post-execute listener accepts with a
// content rewrite. Both the rewrite and the bridge context survive.
// canonical replacement. Both the replacement and the bridge context survive.
const d = dir()
const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n')
const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'rewritten-result' }] }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: [{ type: 'text' as const, text: 'rewritten-result' }] }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -560,7 +560,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.on('tools/post-execute', async () => ({
kind: 'accept' as const,
additionalContexts: [{
@@ -590,7 +590,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
@@ -615,7 +615,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
// yields a HookOutput with exitCode undefined → the `exitCode` spread false arm.
const bash = ctx.bash
bash.run = (() => Promise.reject(new Error('executor down')))
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
@@ -663,7 +663,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') })
ctx.llm.registerAdapter(['mock'], adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const { SessionId } = await import('@deepseek-ai/dsh-session')
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } })

View File

@@ -231,8 +231,7 @@ export function apply(ctx: Context, config: Config): void {
return { ...downstream, additionalContexts: prependContext(context, downstream.additionalContexts) }
}
return {
kind: 'accept',
...downstream.content !== undefined ? { content: downstream.content } : {},
...downstream,
additionalContexts: prependContext(context, downstream.additionalContexts),
}
})

View File

@@ -5,7 +5,7 @@ import { join } from 'node:path'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
@@ -75,7 +75,7 @@ describe('hooks-codex bridge', () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')])
const ctx = await harness(dir, adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'no' }] } }))
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'no' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'run ls' }])
await waitForIdle(ctx, agent)

Some files were not shown because too many files have changed in this diff Show More