Merge branch 'codex/code-mode-typed-results' into codex/code-mode-complete-result-card
# Conflicts: # .agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml
This commit is contained in:
@@ -22,7 +22,7 @@ Every field is validated and defaulted; `maxOutputBytes` is a safe integer of at
|
||||
- **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 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.
|
||||
- **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'`).
|
||||
- **Intermediate binding values are complete JSON** — binding arguments and resolutions cross by structured clone after lossless-JSON validation and have no byte cap. They never enter the outer-output ledger or model context; provider/executor acquisition bounds and process/worker memory remain the limits.
|
||||
- **Intermediate binding values are complete JSON** — binding arguments and resolutions cross by structured clone after iterative lossless-JSON validation and have no byte or call-stack 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. Native writes that bypass the patched stream slots arrive on pipes independent of the completion port; settlement therefore 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 diagnostic. 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.
|
||||
|
||||
@@ -40,71 +40,114 @@ function enumerableStringKeys(value: object): string[] | undefined {
|
||||
if (keys.some(key => typeof key !== 'string' || !Object.prototype.propertyIsEnumerable.call(value, key))) return undefined
|
||||
return keys as string[]
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
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 Set<object>()
|
||||
|
||||
const within = <T extends CodeJsonValue>(source: object, build: () => T | undefined): T | undefined => {
|
||||
if (active.has(source)) return undefined
|
||||
active.add(source)
|
||||
try {
|
||||
return build()
|
||||
} finally {
|
||||
active.delete(source)
|
||||
let root: CodeJsonValue | undefined
|
||||
const assign = (destination: SnapshotDestination, item: CodeJsonValue): void => {
|
||||
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 copy = (candidate: unknown): CodeJsonValue | undefined => {
|
||||
if (candidate === null) return null
|
||||
if (typeof candidate === 'boolean' || typeof candidate === 'string') return candidate
|
||||
const tasks: SnapshotTask[] = [{ kind: 'visit', value, destination: { kind: 'root' } }]
|
||||
for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
|
||||
if (task.kind === 'leave') {
|
||||
active.delete(task.source)
|
||||
continue
|
||||
}
|
||||
if (task.kind === 'array-item') {
|
||||
if (!Object.hasOwn(task.source, task.index)) return undefined
|
||||
tasks.push({
|
||||
kind: 'visit',
|
||||
value: task.source[task.index],
|
||||
destination: { kind: 'array', target: task.target, index: task.index },
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (task.kind === 'object-property') {
|
||||
tasks.push({
|
||||
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') {
|
||||
return Number.isFinite(candidate) && !Object.is(candidate, -0) ? candidate : undefined
|
||||
if (!Number.isFinite(candidate) || Object.is(candidate, -0)) return undefined
|
||||
assign(task.destination, candidate)
|
||||
continue
|
||||
}
|
||||
if (typeof candidate !== 'object') return undefined
|
||||
if (active.has(candidate)) return undefined
|
||||
|
||||
if (Array.isArray(candidate)) {
|
||||
if (!hasPlainArrayPrototype(candidate)) return undefined
|
||||
const length = candidate.length
|
||||
if (Reflect.ownKeys(candidate).length !== length + 1) return undefined
|
||||
return within(candidate, () => {
|
||||
const result: CodeJsonValue[] = []
|
||||
for (let index = 0; index < length; index++) {
|
||||
if (!Object.hasOwn(candidate, index)) return undefined
|
||||
const item = copy(candidate[index])
|
||||
if (item === undefined) return undefined
|
||||
result.push(item)
|
||||
}
|
||||
return result
|
||||
})
|
||||
const target: CodeJsonValue[] = []
|
||||
assign(task.destination, target)
|
||||
active.add(candidate)
|
||||
tasks.push({ kind: 'leave', source: candidate })
|
||||
for (let index = length - 1; index >= 0; index--) {
|
||||
tasks.push({ kind: 'array-item', source: candidate, index, target })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (!hasPlainObjectPrototype(candidate)) return undefined
|
||||
const keys = enumerableStringKeys(candidate)
|
||||
if (keys === undefined) return undefined
|
||||
return within(candidate, () => {
|
||||
const result: Record<string, CodeJsonValue> = {}
|
||||
for (const key of keys) {
|
||||
const item = copy((candidate as Record<string, unknown>)[key])
|
||||
if (item === undefined) return undefined
|
||||
Object.defineProperty(result, key, {
|
||||
value: item,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
const target: Record<string, CodeJsonValue> = {}
|
||||
assign(task.destination, target)
|
||||
active.add(candidate)
|
||||
tasks.push({ 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
|
||||
tasks.push({ kind: 'object-property', source: candidate as Record<string, unknown>, key, target })
|
||||
}
|
||||
}
|
||||
|
||||
return copy(value)
|
||||
return root
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
@@ -74,6 +74,26 @@ describe('WorkerCodeRuntime — programs and bindings (real workers)', () => {
|
||||
expect(calls).toEqual([{ n: 1 }])
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
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: [] })
|
||||
|
||||
@@ -64,6 +64,18 @@ describe('snapshotCodeJsonValue', () => {
|
||||
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
|
||||
|
||||
@@ -31,9 +31,34 @@ type DynamicToolMarker = { [DYNAMIC_TOOL]?: unknown }
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
||||
const prototype: unknown = Object.getPrototypeOf(value)
|
||||
return prototype === null || Object.getPrototypeOf(prototype) === null
|
||||
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 names and points back to its constructor. */
|
||||
function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): boolean {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor')
|
||||
const constructor: unknown = descriptor?.value
|
||||
return typeof constructor === 'function'
|
||||
&& constructor.name === name
|
||||
&& constructor.prototype === prototype
|
||||
}
|
||||
|
||||
/** 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 */
|
||||
|
||||
/** Materialize realm-foreign lossless JSON without allowing JSON.stringify coercions. */
|
||||
function cloneJson(value: unknown, path: string, seen = new Set<object>()): unknown {
|
||||
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value
|
||||
@@ -46,7 +71,7 @@ function cloneJson(value: unknown, path: string, seen = new Set<object>()): unkn
|
||||
seen.add(value)
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
if (Reflect.ownKeys(value).length !== value.length + 1) {
|
||||
if (!hasPlainArrayPrototype(value) || Reflect.ownKeys(value).length !== value.length + 1) {
|
||||
throw new Error(`harness.defineTool ${path} must be lossless JSON data`)
|
||||
}
|
||||
const output: unknown[] = []
|
||||
|
||||
@@ -396,6 +396,7 @@ describe('cordis_mount', () => {
|
||||
['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'],
|
||||
])('rejects a malformed ParameterSchemaSpec (%s) with a teaching error', async (parameters, message) => {
|
||||
const ctx = await setup()
|
||||
|
||||
@@ -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.
|
||||
|
||||
### Surface types
|
||||
|
||||
|
||||
@@ -50,79 +50,127 @@ function enumerableStringKeys(value: object): string[] | 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 (!hasPlainArrayPrototype(current)) return undefined
|
||||
const length = current.length
|
||||
// Every ordinary array owns `length`; dense indexed elements account
|
||||
// for the remaining keys. Anything else would be lost by JSON and by
|
||||
// structured clone, including symbols and non-enumerable properties.
|
||||
if (Reflect.ownKeys(current).length !== length + 1) return undefined
|
||||
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
|
||||
}
|
||||
|
||||
if (!hasPlainObjectPrototype(current)) return undefined
|
||||
const keys = enumerableStringKeys(current)
|
||||
if (keys === undefined) return undefined
|
||||
const snapshot: { [key: string]: JsonValue } = {}
|
||||
for (const key of keys) {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,46 +178,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 (!hasPlainArrayPrototype(value)) return false
|
||||
if (Reflect.ownKeys(value).length !== value.length + 1) 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).
|
||||
if (!hasPlainObjectPrototype(value)) return false
|
||||
const keys = enumerableStringKeys(value)
|
||||
return keys !== undefined && keys.every(key => isJsonValue((value as Record<string, unknown>)[key], seen))
|
||||
} finally {
|
||||
seen.delete(value)
|
||||
}
|
||||
export function isJsonValue(value: unknown): boolean {
|
||||
return walkJsonValue(value, false) === true
|
||||
}
|
||||
|
||||
@@ -80,6 +80,19 @@ describe('snapshotJsonValue', () => {
|
||||
expect(arrayReads).toBe(1)
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
@@ -406,6 +406,18 @@ function snapshotProjection<T>(toolName: string, projector: 'render' | 'presenta
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -1327,13 +1339,10 @@ export class ToolRegistry extends Service {
|
||||
|
||||
/** Snapshot, validate, render, and optionally project one successful body value. */
|
||||
private createSuccessResult(exec: ToolExecution, tool: ToolDefinition, candidate: unknown): ToolExecutionSuccess {
|
||||
const detached = snapshotJsonValue(candidate)
|
||||
if (detached === undefined) {
|
||||
throw new ToolOutputError(tool.name, ['value is not lossless JSON'])
|
||||
}
|
||||
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 as JsonValue)
|
||||
const value = deepFreeze(detached)
|
||||
let rendered: ContentBlock[]
|
||||
try {
|
||||
rendered = tool.output.render(exec.arguments, value)
|
||||
|
||||
@@ -193,6 +193,28 @@ describe('ToolRegistry', () => {
|
||||
expect(mismatch.content[0]?.type === 'text' ? mismatch.content[0].text : '').toContain('"value" must be a string')
|
||||
})
|
||||
|
||||
it('classifies a throwing body snapshot as invalid tool output', async () => {
|
||||
const ctx = await setup()
|
||||
const hostile = Object.defineProperty({}, 'value', {
|
||||
enumerable: true,
|
||||
get: () => { throw new Error('body snapshot getter exploded') },
|
||||
})
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'hostile-body',
|
||||
description: 'hostile body',
|
||||
parameters: {},
|
||||
output: { schema: { type: 'json' }, render: () => [] },
|
||||
execute: async () => hostile as JsonValue,
|
||||
}))
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('hostile-body'), name: 'hostile-body', arguments: {},
|
||||
})
|
||||
expect(result.error?.message).toContain('value snapshot failed: body snapshot getter exploded')
|
||||
expect(result.error?.info).toEqual({ name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' })
|
||||
})
|
||||
|
||||
it.each(['render', 'presentationMeta'] as const)('contains a throwing output.%s projector as one failed call', async (projector) => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(defineTool({
|
||||
|
||||
Reference in New Issue
Block a user