fix(tool-cordis): normalize the JSON-Schema dialect at the defineTool boundary
Field sessions showed models writing tool schemas in the JSON-Schema dialect
by strong prior — type: 'integer', required: false, then the full
{ type:'object', properties, required: [...] } wrapper — and the rejection
text itself pushed a nearly-correct DSL attempt BACK to raw JSON Schema: one
stats tool cost three consecutive schema errors before mounting. The boundary
now normalizes wherever the input has exactly one meaning (wrapper unwrapped
with the required array becoming per-property flags at any nesting level,
integer → number, required: false → optional, all rebuilt as fresh host-realm
objects) and rejects only genuinely meaningless input, enumerating the valid
vocabulary in the error. Re-running the failing session mounts first-try.
The mount description documents both accepted forms.
This commit is contained in:
@@ -1,19 +1,27 @@
|
||||
/**
|
||||
* The registration boundary between sandboxed mount code and the real runtime:
|
||||
* SchemaSpec validation with teaching errors, the marker-guarded
|
||||
* `harness.defineTool` / `harness.registerTool` pair, the guarded `ctx` proxy a
|
||||
* mounted plugin receives, and the plugin-shape helpers the mount lifecycle
|
||||
* narrows sandbox return values with.
|
||||
* SchemaSpec normalization + validation with teaching errors, the
|
||||
* marker-guarded `harness.defineTool` / `harness.registerTool` pair, the
|
||||
* guarded `ctx` proxy a mounted plugin receives, and the plugin-shape helpers
|
||||
* the mount lifecycle narrows sandbox return values with.
|
||||
*
|
||||
* Two realm facts drive the design. Objects built inside the vm carry the vm
|
||||
* realm's `Object.prototype`, and the session log's append-time plainness check
|
||||
* (`dsh-session`'s `isJsonValue`, a prototype-identity comparison) rejects
|
||||
* foreign-realm data — so every dynamic tool's `execute` return is JSON
|
||||
* round-tripped into the host realm before it reaches the registry. And a
|
||||
* malformed tool schema must fail at REGISTRATION, not when a later request
|
||||
* assembles it — so dynamic `ctx.tools.register` calls accept only definitions
|
||||
* produced by the sandbox's `harness.defineTool`, which asserts the SchemaSpec
|
||||
* DSL up front.
|
||||
* round-tripped into the host realm before it reaches the registry, and the
|
||||
* schema itself is rebuilt as fresh host-realm objects. And a malformed tool
|
||||
* schema must fail at REGISTRATION, not when a later request assembles it — so
|
||||
* dynamic `ctx.tools.register` calls accept only definitions produced by the
|
||||
* sandbox's `harness.defineTool`, which normalizes `parameters` up front.
|
||||
*
|
||||
* Normalize, don't lecture, where the input has exactly one meaning: models
|
||||
* write the JSON-Schema dialect by strong prior (the `{ type: 'object',
|
||||
* properties, required: […] }` wrapper, `type: 'integer'`, `required: false`),
|
||||
* and each rejection costs a model turn — so those convert to the SchemaSpec
|
||||
* DSL silently, and only genuinely meaningless input (an unknown type, a
|
||||
* non-boolean `required`) is rejected, with the error enumerating the valid
|
||||
* vocabulary.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-cordis/guard
|
||||
*/
|
||||
@@ -24,6 +32,7 @@ import type { ToolDefinition, ToolExecuteReturn } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
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\''
|
||||
|
||||
type DynamicToolDefinition = ToolDefinition & { [DYNAMIC_TOOL]: true }
|
||||
type DynamicToolMarker = { [DYNAMIC_TOOL]?: unknown }
|
||||
@@ -32,47 +41,70 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Object.prototype.toString.call(value) === '[object Object]'
|
||||
}
|
||||
|
||||
/** Assert a sandbox-provided `parameters` value is a SchemaSpec object, with a teaching error for the common JSON-Schema mistake. */
|
||||
function assertSchemaSpec(value: unknown): void {
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
function normalizeSchemaSpec(value: unknown, path = 'parameters'): Record<string, unknown> {
|
||||
if (!isPlainRecord(value)) {
|
||||
throw new Error('harness.defineTool parameters must be a SchemaSpec object')
|
||||
throw new Error(`harness.defineTool ${path} must be a SchemaSpec object`)
|
||||
}
|
||||
let entries = value
|
||||
const requiredNames = new Set<unknown>()
|
||||
if (value.type === 'object' && isPlainRecord(value.properties)) {
|
||||
throw new Error(
|
||||
'harness.defineTool parameters use the SchemaSpec DSL (NOT JSON Schema).\n'
|
||||
+ ' ✗ { type: \'object\', properties: { name: { type: \'string\' } }, required: [\'name\'] }\n'
|
||||
+ ' ✓ { name: { type: \'string\', required: true } }\n'
|
||||
+ 'Remove the outer { type: \'object\', properties, required } wrapper; '
|
||||
+ 'each key IS a property directly on the parameters object.',
|
||||
)
|
||||
if (Array.isArray(value.required)) {
|
||||
for (const name of value.required) requiredNames.add(name)
|
||||
}
|
||||
entries = value.properties
|
||||
}
|
||||
for (const [key, prop] of Object.entries(value)) {
|
||||
assertSchemaProp(prop, `parameters.${key}`)
|
||||
const spec: Record<string, unknown> = {}
|
||||
for (const [key, prop] of Object.entries(entries)) {
|
||||
spec[key] = normalizeSchemaProp(prop, `${path}.${key}`, requiredNames.has(key))
|
||||
}
|
||||
return spec
|
||||
}
|
||||
|
||||
function assertSchemaProp(value: unknown, path: string): void {
|
||||
/** 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`)
|
||||
}
|
||||
if (!SCHEMA_TYPES.has(value.type)) {
|
||||
throw new Error(`harness.defineTool ${path} must declare a valid type`)
|
||||
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)})`)
|
||||
}
|
||||
if (value.required !== undefined && value.required !== true) {
|
||||
throw new Error(`harness.defineTool ${path}.required must be true when present`)
|
||||
// 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` simply reads as 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 (value.type !== 'object') {
|
||||
if (type !== 'object') {
|
||||
throw new Error(`harness.defineTool ${path}.properties is only valid for type "object"`)
|
||||
}
|
||||
assertSchemaSpec(value.properties)
|
||||
// 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`,
|
||||
)
|
||||
}
|
||||
if (value.items !== undefined) {
|
||||
if (value.type !== 'array') {
|
||||
if (type !== 'array') {
|
||||
throw new Error(`harness.defineTool ${path}.items is only valid for type "array"`)
|
||||
}
|
||||
assertSchemaProp(value.items, `${path}.items`)
|
||||
prop.items = normalizeSchemaProp(value.items, `${path}.items`)
|
||||
}
|
||||
return prop
|
||||
}
|
||||
|
||||
function markDynamicTool(tool: ToolDefinition): DynamicToolDefinition {
|
||||
@@ -87,17 +119,19 @@ function assertDynamicTool(tool: unknown): asserts tool is DynamicToolDefinition
|
||||
}
|
||||
|
||||
/**
|
||||
* The `harness.defineTool` handed into the sandbox: the real DSL, with the
|
||||
* 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 via a JSON round-trip
|
||||
* (see the module doc). The round-trip also projects the return onto exactly
|
||||
* what the log would durably store, so a non-JSON-serializable return surfaces
|
||||
* as that one call's error instead of poisoning the turn.
|
||||
* @param options - the standard `defineTool` options, with `parameters` asserted against the SchemaSpec DSL before the DSL sees them.
|
||||
* @param options - the standard `defineTool` options; `parameters` may be the SchemaSpec 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 {
|
||||
assertSchemaSpec((options as { parameters?: unknown }).parameters)
|
||||
const tool = defineTool(options)
|
||||
const parameters = normalizeSchemaSpec((options as { parameters?: unknown }).parameters)
|
||||
const tool = defineTool({ ...options, parameters } as Parameters<typeof defineTool>[0])
|
||||
const execute = tool.execute.bind(tool)
|
||||
return markDynamicTool({
|
||||
...tool,
|
||||
|
||||
@@ -143,7 +143,10 @@ export function apply(ctx: Context, config: Config): void {
|
||||
+ 'events (see cordis_inspect what:"events"), or call '
|
||||
+ '`harness.registerTool(ctx, harness.defineTool({ name, description, parameters: '
|
||||
+ '{ text: { type: \'string\', required: true } }, async execute(args) { … } }))` '
|
||||
+ 'to give yourself a new tool — it becomes callable on your NEXT step. A '
|
||||
+ '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. '
|
||||
+ 'Mounts can COMPOSE: one plugin may `ctx.provide(\'name\', value)` a service and '
|
||||
|
||||
Reference in New Issue
Block a user