feat: unify JSON value schema DSL
This commit is contained in:
@@ -8,7 +8,7 @@ Tool parameters must reach the model as standard JSON Schema while giving tool a
|
|||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
A small custom DSL in dsh-tools: `SchemaSpec` (per-property specs with `required: true` booleans), type-level `InferArgs<S>` mapping a spec to the argument type (required keys non-optional, others genuinely optional via `?`), a runtime `schemaSpecToJsonSchema()` converter, and `defineTool()` tying them together. Raw JSON-Schema `ToolDefinition`s remain accepted by `ToolRegistry.register()` — that's how MCP-sourced tools arrive.
|
This decision is superseded by the [unified JSON-value schema DSL](2026-07-20-unified-json-value-schema-dsl.md), which retains the small authoring surface while making parameters and typed values share one vocabulary. `ParameterSchemaSpec` keeps per-property `required: true`; `InferArgs<S>` maps required keys to non-optional properties; `parameterSchemaSpecToJsonSchema()` compiles the implicit open object root; and `defineTool()` ties inference, compilation, and validation together. Raw JSON-Schema `ToolDefinition`s remain accepted by `ToolRegistry.register()` for MCP and other external tools.
|
||||||
|
|
||||||
## Alternatives considered
|
## Alternatives considered
|
||||||
|
|
||||||
@@ -17,5 +17,5 @@ A small custom DSL in dsh-tools: `SchemaSpec` (per-property specs with `required
|
|||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
- First-party tool authors get zero-cast typed args; the type gymnastics cost stays inside the core package (sanctioned by the AGENTS.md type-safety policy).
|
- First-party tool authors get zero-cast typed args; the type gymnastics cost stays inside the core package (sanctioned by the AGENTS.md type-safety policy).
|
||||||
- The DSL is deliberately small (string/number/boolean/object/array, enum, default, nested properties/items). Gaps vs full JSON Schema (unions, formats, constraints) are accepted until real tools demand them.
|
- The owning unified note defines the current nodes, literal constraints, unions, JSON-value boundary, and object-openness rules.
|
||||||
- The `InferArgs` mapping is regression-tested at the type level after an early optionality bug.
|
- The `InferArgs` mapping is regression-tested at the type level after an early optionality bug.
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ Status: implemented
|
|||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
|
|
||||||
`defineTool` ([the custom schema DSL](2026-06-11-custom-schema-dsl.md)) gives tool authors a typed `execute(args)` via the `InferArgs<S>` mapping. But that type is a compile-time claim about a value that arrives at runtime as model-generated JSON: nothing forced the model to honor the schema, so a malformed call — missing a required key, a string where a number was declared, an enum value outside the set — reached `execute` typed-in-name-only. The tool body then either crashed on the bad shape (a generic stack trace the model can't act on) or, worse, silently misbehaved. Meanwhile the converter already encodes the exact structure a validator would need to walk.
|
`defineTool` ([the unified schema DSL](2026-07-20-unified-json-value-schema-dsl.md)) gives tool authors a typed `execute(args)` via the `InferArgs<S>` mapping. But that type is a compile-time claim about a value that arrives at runtime as model-generated JSON: nothing forced the model to honor the schema, so a malformed call — missing a required key, a string where a number was declared, or a literal outside the declared set — reached `execute` typed-in-name-only. The tool body then either crashed on the bad shape or silently misbehaved.
|
||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
`validateArgs(spec, args): string[]` interprets a `SchemaSpec` over a runtime value, returning human-readable violations (empty = valid), and is total (never throws). `defineTool` runs it before the typed body; on violations it throws `ToolArgsError` (`code: 'INVALID_ARGS'`, message listing the violations), which the registry's existing execute-waterfall catch turns into an `isError` result the model reads and self-corrects from.
|
`validateArgs(spec, args): string[]` compiles a `ParameterSchemaSpec` and delegates to the shared `validateJsonSchemaValue()` walker, returning human-readable violations for a well-formed declaration. `defineTool` snapshots the compiled parameter schema at definition time and runs that validation before the typed body; violations throw `ToolArgsError` (`INVALID_ARGS`), which the registry returns as an error result the model can correct.
|
||||||
|
|
||||||
The validator mirrors `schemaSpecToJsonSchema` semantics exactly — same structure walked, same rules: 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. Raw-registered (MCP) tools are not touched — they validate their own input.
|
The validator and compiler therefore share exact semantics: the implicit parameter root is an open object; required keys come only from `required: true`; defaults remain annotations; explicit nested objects honor their declared openness; arrays recurse through `items`; scalar literal constraints are type-correct; and `oneOf` accepts exactly one matching branch. Raw-registered tools own their input validation.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||||
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||||
|
# after editing either side, bring the other along and re-record with:
|
||||||
|
# pnpm run verify-translation-pairing --write
|
||||||
|
2026-07-20-unified-json-value-schema-dsl.md: ab7bb268407ac26230283172ebb291de80412bf2
|
||||||
|
2026-07-20-unified-json-value-schema-dsl.zh.md: 479699fc2d58666b86861f0ea1db907ae4957dc5
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Agent Note: Unified JSON-value schema DSL
|
||||||
|
|
||||||
|
Status: implemented
|
||||||
|
|
||||||
|
English | [中文](2026-07-20-unified-json-value-schema-dsl.zh.md)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Tool parameters used a small author DSL while subagent/workflow structured output used a separate raw JSON Schema subset and validator. The two vocabularies disagreed about roots, scalar constraints, and validation, so a typed canonical tool-output contract would either duplicate both paths again or accept schemas that some projection could not enforce.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
`dsh-tools` owns one JSON-value schema vocabulary with two representations. `ValueSchemaSpec` is the author form for any JSON root; `ParameterSchemaSpec` is its implicit object-property-map form with per-property `required: true`. `JsonSchemaNode` is the raw wire form. Both support string, finite number, integer, boolean, null, array, object, type-correct scalar `enum`/`const`, and exact-one `oneOf`; `{ type: 'json' }` is author-only sugar for an annotation-only unconstrained raw node.
|
||||||
|
|
||||||
|
An explicit author object must declare `additionalProperties: true | false`. The implicit parameter root and raw JSON Schema preserve the standard open default. `InferValue<S>` and `InferArgs<P>` derive TypeScript values from the same declarations that `valueSchemaSpecToJsonSchema()` and `parameterSchemaSpecToJsonSchema()` compile. `assertSupportedJsonSchema()` rejects unsupported or misplaced keywords, and `validateJsonSchemaValue()` enforces the accepted subset against the lossless `JsonValue` boundary: no `undefined`, negative zero, non-finite numbers, sparse arrays, cycles, exotic objects, functions, symbols, or other coercive values.
|
||||||
|
|
||||||
|
Object-rooting is a consumer rule rather than a vocabulary restriction. Subagent and workflow caller-defined structured outputs use `assertObjectJsonSchema()` and `ObjectJsonSchema`; tool outputs may use any root. Dynamic Cordis registrations rebuild realm-foreign schemas into host-owned JSON, preserve raw-wrapper openness, and require direct-DSL object openness before calling the same compiler.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Keep separate parameter and structured-output schema systems:** rejected because every added output construct would require parallel inference, compilation, validation, and code-generation changes with no useful ownership boundary.
|
||||||
|
- **Adopt full JSON Schema or Ajv:** rejected because the harness must fail on every construct it cannot project into its generated SDK and validators; accepting a larger language would make enforcement and model guidance dishonest.
|
||||||
|
- **Make every object implicitly open or closed:** rejected because either choice hides a consequential author decision. Only the legacy-shaped implicit parameter root and external raw schema retain an intentional default.
|
||||||
|
- **Define `oneOf` as first-match:** rejected because branch ordering would change validation semantics and allow overlapping branches to hide ambiguous values.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Parameter validation, output validation, schema-to-TypeScript generation, subagent/workflow guards, and dynamic registration share one enforced vocabulary.
|
||||||
|
- Output declarations can infer object, array, scalar, or null roots; subagent/workflow structured outputs remain object-rooted at their existing seams.
|
||||||
|
- Explicit object openness and type-correct literal constraints make malformed declarations fail during authoring or registration rather than during a later model call.
|
||||||
|
- Raw tools may still register broader JSON Schema directly, but unified code generation treats unsupported schemas as unknown instead of pretending to enforce them.
|
||||||
|
- Runtime and compile-time tests cover every root, exact-one overlap/no-match behavior, raw open defaults, explicit openness, lossy JSON values, and inference.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Agent Note:统一 JSON 值 schema DSL
|
||||||
|
|
||||||
|
Status: implemented
|
||||||
|
|
||||||
|
[English](2026-07-20-unified-json-value-schema-dsl.md) | 中文
|
||||||
|
|
||||||
|
## 问题
|
||||||
|
|
||||||
|
工具参数使用一套精简的作者侧 schema DSL,subagent/工作流的结构化输出则使用另一套原始 JSON Schema 子集和校验器。两套词汇在根类型、标量约束和校验方式上并不一致;如果继续沿用这种划分,类型化的规范工具输出契约要么还需重复实现两条路径,要么只能接受部分投影无法强制执行的 schema。
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
`dsh-tools` 以两种表示形式统一管理一套 JSON 值 schema 词汇。`ValueSchemaSpec` 是可描述任意 JSON 根类型的作者侧形式;`ParameterSchemaSpec` 是其隐式对象属性映射形式,每个属性可标记 `required: true`。`JsonSchemaNode` 是原始协议表示。两种形式都支持字符串、有限数值、整数、布尔值、null、数组、对象、类型正确的标量 `enum`/`const`,以及要求恰好匹配一个分支的 `oneOf`;`{ type: 'json' }` 仅是作者侧语法糖,会编译为仅含注解、不施加约束的原始节点。
|
||||||
|
|
||||||
|
显式的作者侧对象必须声明 `additionalProperties: true | false`。隐式参数根对象和原始 JSON Schema 保留标准的默认开放语义。`InferValue<S>` 和 `InferArgs<P>` 根据同一份声明推导 TypeScript 值,`valueSchemaSpecToJsonSchema()` 和 `parameterSchemaSpecToJsonSchema()` 也将这些声明编译为 JSON Schema。`assertSupportedJsonSchema()` 会拒绝不受支持或位置错误的关键字;`validateJsonSchemaValue()` 则以无损 `JsonValue` 边界校验受支持的子集,不允许 `undefined`、负零、非有限数、稀疏数组、循环引用、非普通对象、函数、symbol 及其他需要强制转换的值。
|
||||||
|
|
||||||
|
对象根限制属于消费方规则,不属于 schema 词汇本身。subagent 和工作流中由调用方定义的结构化输出通过 `assertObjectJsonSchema()` 和 `ObjectJsonSchema` 保持对象根限制;工具输出可以使用任意根类型。动态 Cordis 注册会把跨 JavaScript 运行域传入的 schema 重建为当前运行时持有的 JSON,保留原始包装层的默认开放语义,并要求直接使用 DSL 声明的对象明确选择开放方式,然后再调用同一编译器。
|
||||||
|
|
||||||
|
## 备选方案
|
||||||
|
|
||||||
|
- **保留两套独立的参数与结构化输出 schema 系统:**不予采纳。每新增一种输出结构,都必须分别修改类型推导、编译、校验和代码生成,而这种重复并未形成有意义的职责边界。
|
||||||
|
- **采用完整 JSON Schema 或 Ajv:**不予采纳。harness 必须拒绝所有无法投影到生成 SDK 和校验器中的结构;如果接受更大的语言子集,强制执行能力和模型指引就会与事实不符。
|
||||||
|
- **让所有对象默认开放或默认封闭:**不予采纳。这两种选择都会隐藏一项影响重大的作者决策。只有保持旧有形态的隐式参数根对象和外部原始 schema 才有意保留默认值。
|
||||||
|
- **把 `oneOf` 定义为首个匹配分支:**不予采纳。这样一来,分支顺序会改变校验语义,重叠分支也会掩盖值的歧义。
|
||||||
|
|
||||||
|
## 影响
|
||||||
|
|
||||||
|
- 参数校验、输出校验、schema 到 TypeScript 的代码生成、subagent/工作流门禁和动态注册共用一套强制执行的词汇。
|
||||||
|
- 输出声明可以推导对象、数组、标量或 null 根类型;subagent/工作流的结构化输出仍在其现有服务边界保持对象根限制。
|
||||||
|
- 显式的对象开放方式和类型正确的字面量约束会让格式错误的声明在编写或注册阶段快速失败,而不是拖到后续模型调用时才失败。
|
||||||
|
- 原始工具仍可直接注册范围更广的 JSON Schema,但统一代码生成会把不受支持的 schema 视为未知类型,不会假装自己能够强制执行。
|
||||||
|
- 运行时和编译期测试覆盖所有根类型、恰好匹配一个分支时的重叠/无匹配行为、原始 schema 的默认开放语义、显式开放方式、有损 JSON 值和类型推导。
|
||||||
@@ -44,7 +44,7 @@ A `workflow` tool mirroring `dsh-tool-subagent`'s synchronous shape: start, awai
|
|||||||
|
|
||||||
An output schema makes a schema-valid committed capture mandatory for successful child completion. The scoped runtime presents the capture tool and instruction, commits only a successful final outcome—including the enclosing `run_code` outcome for an SDK call—denies later side effects after capture becomes pending, and stops the child without another model step after commit. A validation failure remains a retryable tool error; clean completion without a committed capture settles as an error.
|
An output schema makes a schema-valid committed capture mandatory for successful child completion. The scoped runtime presents the capture tool and instruction, commits only a successful final outcome—including the enclosing `run_code` outcome for an SDK call—denies later side effects after capture becomes pending, and stops the child without another model step after commit. A validation failure remains a retryable tool error; clean completion without a committed capture settles as an error.
|
||||||
|
|
||||||
`StructuredOutputSchema` is the raw enforceable JSON-Schema subset in `dsh-tools` (single-string `type`, `properties`/`required`/`additionalProperties`, `items`, scalar `enum`/`const`), and unsupported keywords fail loudly because that wire data becomes the capture tool's parameters verbatim. The [agent-scope runtime-design Agent Note](../architecture/2026-07-12-agent-scope-runtime-design.md#structured-output-commits-only-authoritative-outcomes) owns the assembly, commit, guard, and terminal-stop correctness algorithms.
|
`ObjectJsonSchema` is the object-rooted consumer view of the unified enforceable raw JSON Schema subset in `dsh-tools`; unsupported keywords fail loudly because that wire data becomes the capture tool's parameters verbatim. The [unified JSON-value schema Agent Note](../architecture/2026-07-20-unified-json-value-schema-dsl.md) owns the vocabulary and validation semantics, while the [agent-scope runtime-design Agent Note](../architecture/2026-07-12-agent-scope-runtime-design.md#structured-output-commits-only-authoritative-outcomes) owns the assembly, commit, guard, and terminal-stop algorithms.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ Worker-side logic runs through an in-process `MessageChannel` so V8 coverage mea
|
|||||||
- **Background execution as the default** (CC's shape): deferred; foreground-synchronous matches `dsh-tool-subagent`'s cut, and background semantics should be designed ONCE across bash/subagent/workflow rather than per-tool.
|
- **Background execution as the default** (CC's shape): deferred; foreground-synchronous matches `dsh-tool-subagent`'s cut, and background semantics should be designed ONCE across bash/subagent/workflow rather than per-tool.
|
||||||
- **Workflow-layer JSON parsing for `agent({schema})`**: duplicating a seam concern at one consumer while the seam's capability flag stayed dishonestly `false`.
|
- **Workflow-layer JSON parsing for `agent({schema})`**: duplicating a seam concern at one consumer while the seam's capability flag stayed dishonestly `false`.
|
||||||
- **Meta embedded in the script as `export const meta = {...}`** (CC's exact format): keeps scripts self-contained and CC scripts drop-in, but obtaining meta requires evaluating model-written text on the host. Even an empty timed vm context cannot bound script-controlled getters when the host reads the resulting object. A JSON parameter removes the scanner, evaluation, and host-spin hole; the cost is that a CC script's meta header must move into the parameter (the body stays drop-in).
|
- **Meta embedded in the script as `export const meta = {...}`** (CC's exact format): keeps scripts self-contained and CC scripts drop-in, but obtaining meta requires evaluating model-written text on the host. Even an empty timed vm context cannot bound script-controlled getters when the host reads the resulting object. A JSON parameter removes the scanner, evaluation, and host-spin hole; the cost is that a CC script's meta header must move into the parameter (the body stays drop-in).
|
||||||
- **`SchemaSpec` as the outputSchema type**: the author-facing DSL cannot express what arrives as data and cannot be validated against without conversion loss.
|
- **`ValueSchemaSpec` as the `outputSchema` wire type**: the author form now has equivalent vocabulary, but a workflow supplies realm-foreign raw JSON Schema data; pretending that runtime data is a trusted author declaration would skip the raw-schema assertion boundary.
|
||||||
- **A schema-object library (zod, or the repo's schemastery) for the structured-output subset**: the schema is wire data — plain JSON that crosses the vm realm boundary in `agent({schema})` and lands verbatim in the forced tool's parameters — exactly where live schema objects cannot sit; consuming raw JSON Schema at runtime would need a third-party converter on top (zod core only emits JSON Schema, not the reverse), and it would put a second schema language beside schemastery's config role.
|
- **A schema-object library (zod, or the repo's schemastery) for the structured-output subset**: the schema is wire data — plain JSON that crosses the vm realm boundary in `agent({schema})` and lands verbatim in the forced tool's parameters — exactly where live schema objects cannot sit; consuming raw JSON Schema at runtime would need a third-party converter on top (zod core only emits JSON Schema, not the reverse), and it would put a second schema language beside schemastery's config role.
|
||||||
- **ajv for value validation**: it validates FULL JSON Schema, so the subset gate — the module's actual point, since every accepted keyword must be one the harness enforces — would remain hand-written regardless; it compiles validators through `new Function`; and it would be dsh-tools' first runtime dependency, all to replace the ~70-line value walker while the path-qualified, every-violation error reporting stays custom either way.
|
- **ajv for value validation**: it validates FULL JSON Schema, so the subset gate — the module's actual point, since every accepted keyword must be one the harness enforces — would remain hand-written regardless; it compiles validators through `new Function`; and it would be dsh-tools' first runtime dependency, all to replace the ~70-line value walker while the path-qualified, every-violation error reporting stays custom either way.
|
||||||
- **Provider JSON mode instead of the capture tool:** it guarantees valid JSON, not schema conformance, and its interaction with tool calling is unclear. The capture tool preserves in-turn validation retries. Provider-side strict tool schemas can later narrow the accepted subset without changing this design.
|
- **Provider JSON mode instead of the capture tool:** it guarantees valid JSON, not schema conformance, and its interaction with tool calling is unclear. The capture tool preserves in-turn validation retries. Provider-side strict tool schemas can later narrow the accepted subset without changing this design.
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ Sandbox globals are deliberately small: a tagged write-through `console` (`[cord
|
|||||||
|
|
||||||
Mount code crosses the vm boundary through three controls. Dual-realm `instanceof` recognizes both host and vm objects. `harness.defineTool` normalizes results into host-realm JSON and validates the `ToolExecuteReturn` shape before logging. The mounted plugin receives a whitelist context façade, not a raw or pass-through `Context`; framework plumbing and context-valued returns are rejected. Service reads require a declared `inject`, preserving Cordis activation and unload semantics. `ctx.tools.get` exposes only the schema view, so mounted code cannot bypass `ToolRegistry.execute` by calling a definition directly.
|
Mount code crosses the vm boundary through three controls. Dual-realm `instanceof` recognizes both host and vm objects. `harness.defineTool` normalizes results into host-realm JSON and validates the `ToolExecuteReturn` shape before logging. The mounted plugin receives a whitelist context façade, not a raw or pass-through `Context`; framework plumbing and context-valued returns are rejected. Service reads require a declared `inject`, preserving Cordis activation and unload semantics. `ctx.tools.get` exposes only the schema view, so mounted code cannot bypass `ToolRegistry.execute` by calling a definition directly.
|
||||||
|
|
||||||
The boundary normalizes unambiguous JSON-Schema forms into `SchemaSpec`, including object wrappers, `integer`, and optional fields. Invalid vocabulary fails with the accepted alternatives. Parse, TypeScript, missing-return, Node-API, and duplicate-tool errors include the relevant source line or corrective contract without narrating implementation internals.
|
The boundary normalizes unambiguous JSON-Schema forms into `ParameterSchemaSpec`, preserving `integer`, raw object openness, and required arrays. Direct DSL object nodes must declare `additionalProperties`; invalid vocabulary fails with the accepted alternatives. Parse, TypeScript, missing-return, Node-API, and duplicate-tool errors include the relevant source line or corrective contract without narrating implementation internals.
|
||||||
|
|
||||||
### The dynamic group and mount lifecycle
|
### The dynamic group and mount lifecycle
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ Model-visible ⟺ logged holds with no new session event type: a mount or unmoun
|
|||||||
|
|
||||||
| Dimension | Structured per-capability tools | Single `cordis_mount` |
|
| Dimension | Structured per-capability tools | Single `cordis_mount` |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Schema correctness | `parameters` is still a model-written JSON object needing SchemaSpec validation, merely one step earlier | The same validation runs at the sandbox boundary, with the same instructive errors |
|
| Schema correctness | `parameters` is still model-written JSON needing unified-schema validation, merely one step earlier | The same validation runs at the sandbox boundary, with the same instructive errors |
|
||||||
| The code field | An `execute` body is still model-written JS in a vm; the realm and service-call correctness problems are unchanged | One sandbox, one normalization path, one guarded registration |
|
| The code field | An `execute` body is still model-written JS in a vm; the realm and service-call correctness problems are unchanged | One sandbox, one normalization path, one guarded registration |
|
||||||
| Capability coverage | Tools only; listeners, services, `inject` relations each need another structured tool — a surface that grows without bound | One vocabulary (a cordis plugin) covers every effect, present and future |
|
| Capability coverage | Tools only; listeners, services, `inject` relations each need another structured tool — a surface that grows without bound | One vocabulary (a cordis plugin) covers every effect, present and future |
|
||||||
| Cross-mount composition | Not expressible in a tool-registration payload | Native `provide`/`inject`, ordinary cordis semantics |
|
| Cross-mount composition | Not expressible in a tool-registration payload | Native `provide`/`inject`, ordinary cordis semantics |
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ The scoping line was not picked top-down; it was discovered by testing candidate
|
|||||||
The rule that settled the remaining cases: ***the type you write, hold, or receive is core; the machinery that types it, renders it, or persists it is a sub-page detail.*** Worked through:
|
The rule that settled the remaining cases: ***the type you write, hold, or receive is core; the machinery that types it, renders it, or persists it is a sub-page detail.*** Worked through:
|
||||||
|
|
||||||
- A data structure is **core** if it flows through the agent-loop spine — the loop holds, derives, streams, or logs it on every turn regardless of which plugins load (`Message`, `StreamChunk`, `SessionEvent`, the `Agent` handle) — **or** it is the single headline type a plugin author writes against a pipeline (`ToolDefinition`).
|
- A data structure is **core** if it flows through the agent-loop spine — the loop holds, derives, streams, or logs it on every turn regardless of which plugins load (`Message`, `StreamChunk`, `SessionEvent`, the `Agent` handle) — **or** it is the single headline type a plugin author writes against a pipeline (`ToolDefinition`).
|
||||||
- `ToolDefinition` is core (it is what every tool author writes) **even though the loop never holds one** — authoring-importance overrides the strict flows-through-spine rule for this one headline type. But its typing machinery — the `SchemaSpec`/`InferArgs` DSL — is a sub-page detail (you write a `ToolDefinition`; the type-level machinery that types it you do not). That is the spine-vs-seam line made sharp.
|
- `ToolDefinition` is core (it is what every tool author writes) **even though the loop never holds one** — authoring-importance overrides the strict flows-through-spine rule for this one headline type. But its typing machinery — `ValueSchemaSpec`, `ParameterSchemaSpec`, `InferValue`, and `InferArgs` — is a sub-page detail. That is the spine-vs-seam line made sharp.
|
||||||
- `ToolSchema` is core (it is a field of `GenerateOptions`, the model request that flows through every step) even though it is conceptually part of the tool pipeline — *flows through the spine* wins over *conceptual home* when they conflict.
|
- `ToolSchema` is core (it is a field of `GenerateOptions`, the model request that flows through every step) even though it is conceptually part of the tool pipeline — *flows through the spine* wins over *conceptual home* when they conflict.
|
||||||
- The tool-presentation vocabulary (`ToolCallView`/`ToolResultView`, …), the `SessionPersistence` durability seam, and bash vocabulary are sub-pages.
|
- The tool-presentation vocabulary (`ToolCallView`/`ToolResultView`, …), the `SessionPersistence` durability seam, and bash vocabulary are sub-pages.
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Adopt `fast-check` (a root devDependency) with one `tests/properties.spec.ts` pe
|
|||||||
|
|
||||||
- **dsh-llm / BlockAssembler:** arbitrary chunk streams (valid + malformed: duplicate indices, stragglers, missing block-start). Invariants: `blocks()` count ≤ distinct indices seen; re-assembly idempotent (`blocks()` is stable across repeated calls and `message().content` mirrors it); `blocks()` never throws and yields only valid content-block tags; `finish` reflects the last `finish` chunk, defaulting to `{kind:'stop'}` when none arrives.
|
- **dsh-llm / BlockAssembler:** arbitrary chunk streams (valid + malformed: duplicate indices, stragglers, missing block-start). Invariants: `blocks()` count ≤ distinct indices seen; re-assembly idempotent (`blocks()` is stable across repeated calls and `message().content` mirrors it); `blocks()` never throws and yields only valid content-block tags; `finish` reflects the last `finish` chunk, defaulting to `{kind:'stop'}` when none arrives.
|
||||||
- **dsh-session:** arbitrary event logs. Invariants: `deriveMessages` deterministic; replay-from-seed identical; seq strictly monotonic; non-message events never affect derived history; derived content is decoupled from the log.
|
- **dsh-session:** arbitrary event logs. Invariants: `deriveMessages` deterministic; replay-from-seed identical; seq strictly monotonic; non-message events never affect derived history; derived content is decoupled from the log.
|
||||||
- **dsh-tools:** arbitrary `SchemaSpec`. Invariants: JSON Schema `required` equals the `required:true` keys at every level; conversion total; **and the composition with [runtime arg validation](../architecture/2026-06-11-runtime-arg-validation.md)** — generated args satisfying a spec pass `validateArgs`, and targeted corruptions (dropped required key, non-object top level) are rejected. This closes the validator/`InferArgs` drift risk.
|
- **dsh-tools:** arbitrary `ParameterSchemaSpec`. Invariants: JSON Schema `required` equals the `required:true` keys at every level; conversion is total for valid declarations; **and the composition with [runtime arg validation](../architecture/2026-06-11-runtime-arg-validation.md)** — generated args satisfying a spec pass `validateArgs`, and targeted corruptions (dropped required key, non-object top level) are rejected. Focused cases cover every value root, exact-one overlap/no-match, explicit openness, raw defaults, and lossy JSON. This closes the compiler/validator/`InferArgs` drift risk.
|
||||||
- **dsh-agent-loop:** arbitrary send schedules against a never-exhausting adapter, driven through the `agent/status` settle signal (no wall-clock sleeps). Invariants: no message lost; turn numbers strictly increase; status transitions stay on the legal machine.
|
- **dsh-agent-loop:** arbitrary send schedules against a never-exhausting adapter, driven through the `agent/status` settle signal (no wall-clock sleeps). Invariants: no message lost; turn numbers strictly increase; status transitions stay on the legal machine.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ The [subagent seam](../../implemented/feature/2026-06-21-subagent-capability-sea
|
|||||||
- **`outputSchema`/`structured` and `toolFilter`** (`SubagentCapabilities`, `SubagentStartRequest`, `SubagentResult` in `packages/subagent/subagent/src/types.ts`): at the decision point, every real provider declared `outputSchema: false, toolFilter: false` (`packages/subagent/subagent-spawn/src/index.ts`, `packages/subagent/subagent-fork/src/index.ts`, `packages/subagent/subagent-acp/src/index.ts`); the sole production `ctx.subagents.start` caller (`packages/subagent/tool-subagent/src/index.ts`) built `{ prompt, parent, signal?, agentOptions? }` and structurally could not set either; `structured` appeared only in the scripted test fixture. The service's capability check carried two assert rows whose only exercisers were the rejection tests.
|
- **`outputSchema`/`structured` and `toolFilter`** (`SubagentCapabilities`, `SubagentStartRequest`, `SubagentResult` in `packages/subagent/subagent/src/types.ts`): at the decision point, every real provider declared `outputSchema: false, toolFilter: false` (`packages/subagent/subagent-spawn/src/index.ts`, `packages/subagent/subagent-fork/src/index.ts`, `packages/subagent/subagent-acp/src/index.ts`); the sole production `ctx.subagents.start` caller (`packages/subagent/tool-subagent/src/index.ts`) built `{ prompt, parent, signal?, agentOptions? }` and structurally could not set either; `structured` appeared only in the scripted test fixture. The service's capability check carried two assert rows whose only exercisers were the rejection tests.
|
||||||
- **`SubagentRun.sendMessage` / `SubagentRun.resume`** (same file): implemented by NO provider — not even the mock; the spawn spec asserts their *absence*.
|
- **`SubagentRun.sendMessage` / `SubagentRun.resume`** (same file): implemented by NO provider — not even the mock; the spawn spec asserts their *absence*.
|
||||||
|
|
||||||
The only reason `dsh-subagent` depends on `dsh-tools` at all is `outputSchema`'s `SchemaSpec` type. Three subsequent subagent workstreams (per-session snapshot replay, the fork seed boundary, the ACP backend) landed around this surface without growing a single consumer.
|
The only reason `dsh-subagent` depended on `dsh-tools` at the decision point was `outputSchema`'s schema type (now `ObjectJsonSchema`). Three subsequent subagent workstreams (per-session snapshot replay, the fork seed boundary, the ACP backend) landed around this surface without growing a single consumer.
|
||||||
|
|
||||||
## Proposal
|
## Proposal
|
||||||
|
|
||||||
|
|||||||
@@ -1303,7 +1303,7 @@ export interface Config {
|
|||||||
export type ToolPresentationMode = 'native' | 'code' | 'both'
|
export type ToolPresentationMode = 'native' | 'code' | 'both'
|
||||||
```
|
```
|
||||||
|
|
||||||
Source: [`packages/core/tools/src/index.ts:382`](../packages/core/tools/src/index.ts)
|
Source: [`packages/core/tools/src/index.ts:397`](../packages/core/tools/src/index.ts)
|
||||||
|
|
||||||
## `@deepseek-ai/dsh-tui`
|
## `@deepseek-ai/dsh-tui`
|
||||||
|
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||||
# after editing either side, bring the other along and re-record with:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write
|
# pnpm run verify-translation-pairing --write
|
||||||
adding-a-tool.md: a45315dc0ec92ab28963c2aca32dffcf5f778dcd
|
adding-a-tool.md: 94cb4fcfa9a0155fd57ef18f9a855a264c2eff84
|
||||||
adding-a-tool.zh.md: f574957ddd0e42cedc93ddc0f3270110a8f110c5
|
adding-a-tool.zh.md: 637dc3381765e57c2d0420164ac13fde7fb590fe
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool (w
|
|||||||
|
|
||||||
## Rules of the execute() contract
|
## Rules of the execute() contract
|
||||||
|
|
||||||
- **Args are validated for you.** `defineTool` validates the model-generated `arguments` against the `SchemaSpec` before `execute` runs (type, required keys, enum membership, nested objects/arrays — [runtime arg validation](../../.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.md)), so inside `execute` the args already match `InferArgs`. You still hand-check value constraints the DSL can't express (non-empty strings, positive numbers, cross-field rules); throw a descriptive Error for those. Raw JSON-Schema tools registered directly (MCP) are NOT validated by the harness — they validate their own input.
|
- **Args are validated for you.** `defineTool` validates model-generated `arguments` against the unified `ParameterSchemaSpec` before `execute` runs (types, required keys, literal constraints, exact-one unions, and nested values — [runtime arg validation](../../.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.md)), so inside `execute` the args match `InferArgs`. Explicit object nodes declare `additionalProperties: true | false`; the implicit parameter root stays open. You still hand-check constraints the DSL does not express, such as non-empty strings, positive numbers, or cross-field rules. Raw JSON-Schema tools registered directly own their input validation.
|
||||||
- **Registration borrows your readonly definition.** A typed same-process contribution is not a serialization boundary; do not mutate its schema or replace callbacks after registration. `schemas()` materializes only the explicit model-facing projection. To hot-swap a tool, dispose its owning effect and register the replacement; mutable state inside the callback's closure remains ordinary plugin state.
|
- **Registration borrows your readonly definition.** A typed same-process contribution is not a serialization boundary; do not mutate its schema or replace callbacks after registration. `schemas()` materializes only the explicit model-facing projection. To hot-swap a tool, dispose its owning effect and register the replacement; mutable state inside the callback's closure remains ordinary plugin state.
|
||||||
- **Execution identity is protected.** The registry materializes `arguments` as detached lossless JSON in one recursive pass, freezes that value before policy starts, and assigns an opaque `exec.token`; `callId`, `name`, `arguments`, `agent`, `token`, and an optional enclosing-transport `parent` token stay immutable through dispatch. `parent` is identity-only and exposes no live outer execution. Treat `args` as readonly input. An around-dispatch wrapper may add, replace, or remove only `exec.signal` to impose cancellation or a deadline.
|
- **Execution identity is protected.** The registry materializes `arguments` as detached lossless JSON in one recursive pass, freezes that value before policy starts, and assigns an opaque `exec.token`; `callId`, `name`, `arguments`, `agent`, `token`, and an optional enclosing-transport `parent` token stay immutable through dispatch. `parent` is identity-only and exposes no live outer execution. Treat `args` as readonly input. An around-dispatch wrapper may add, replace, or remove only `exec.signal` to impose cancellation or a deadline.
|
||||||
- **Throwing or returning non-JSON data means `isError`.** The registry catches throws and materializes the final result before observers run. A malformed or non-JSON result becomes `{ isError: true }`, preventing a live success that cannot be logged. Throw for infrastructure failures; report domain failures in result text when the model must interpret them.
|
- **Throwing or returning non-JSON data means `isError`.** The registry catches throws and materializes the final result before observers run. A malformed or non-JSON result becomes `{ isError: true }`, preventing a live success that cannot be logged. Throw for infrastructure failures; report domain failures in result text when the model must interpret them.
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function apply(ctx: Context) {
|
|||||||
|
|
||||||
## execute() 契约的规则
|
## execute() 契约的规则
|
||||||
|
|
||||||
- **参数已为你校验。** `defineTool` 在 `execute` 运行前,会根据 `SchemaSpec` 校验模型生成的 `arguments`(类型、必填键、枚举成员、嵌套对象/数组——见[运行时参数校验](../../.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.md)),因此 `execute` 内部的 args 已匹配 `InferArgs`。你仍需手动检查 DSL 无法表达的值约束(非空字符串、正数、跨字段规则),对这些情况抛出描述性 Error。直接注册的原始 JSON-Schema 工具(MCP)不由 harness 校验,它们自行校验输入。
|
- **参数已为你校验。** `defineTool` 在 `execute` 运行前,会根据统一的 `ParameterSchemaSpec` 校验模型生成的 `arguments`(类型、必填键、字面量约束、恰好匹配一个分支的联合以及嵌套值——见[运行时参数校验](../../.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.md)),因此 `execute` 内的 args 会匹配 `InferArgs`。显式对象节点必须声明 `additionalProperties: true | false`;隐式参数根对象保持开放。你仍需手动检查 schema DSL 无法表达的约束,例如非空字符串、正数或跨字段规则。直接注册的原始 JSON Schema 工具自行负责输入校验。
|
||||||
- **注册借用你的只读定义。** 类型化的同进程贡献不是序列化边界;注册后不要修改其 schema 或替换回调。`schemas()` 只物化显式的模型可见投影。如需热替换工具,请 dispose 其所属副作用并注册替代品;回调闭包内的可变状态仍是普通的插件状态。
|
- **注册借用你的只读定义。** 类型化的同进程贡献不是序列化边界;注册后不要修改其 schema 或替换回调。`schemas()` 只物化显式的模型可见投影。如需热替换工具,请 dispose 其所属副作用并注册替代品;回调闭包内的可变状态仍是普通的插件状态。
|
||||||
- **执行身份受保护。** 注册表在一次递归遍历中将 `arguments` 物化为分离的无损 JSON,在策略开始前冻结该值,并分配一个不透明的 `exec.token`;`callId`、`name`、`arguments`、`agent`、`token` 以及可选的外层传输 `parent` token 在整个分发过程中保持不可变。`parent` 仅用于身份标识,不暴露活跃的外层执行。请将 `args` 视为只读输入。around-dispatch 包装器只能添加、替换或移除 `exec.signal`,以施加取消或截止时间。
|
- **执行身份受保护。** 注册表在一次递归遍历中将 `arguments` 物化为分离的无损 JSON,在策略开始前冻结该值,并分配一个不透明的 `exec.token`;`callId`、`name`、`arguments`、`agent`、`token` 以及可选的外层传输 `parent` token 在整个分发过程中保持不可变。`parent` 仅用于身份标识,不暴露活跃的外层执行。请将 `args` 视为只读输入。around-dispatch 包装器只能添加、替换或移除 `exec.signal`,以施加取消或截止时间。
|
||||||
- **抛出异常或返回非 JSON 数据意味着 `isError`。** 注册表捕获异常,并在观察者运行前物化最终结果。格式错误或非 JSON 的结果变为 `{ isError: true }`,防止出现无法记录的活跃成功。基础设施故障请抛异常;当模型需要解读领域失败时,请在结果文本中报告。
|
- **抛出异常或返回非 JSON 数据意味着 `isError`。** 注册表捕获异常,并在观察者运行前物化最终结果。格式错误或非 JSON 的结果变为 `{ isError: true }`,防止出现无法记录的活跃成功。基础设施故障请抛异常;当模型需要解读领域失败时,请在结果文本中报告。
|
||||||
|
|||||||
@@ -760,7 +760,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai
|
|||||||
'tools/change'(): void
|
'tools/change'(): void
|
||||||
```
|
```
|
||||||
|
|
||||||
Source: [`packages/core/tools/src/index.ts:116`](../../packages/core/tools/src/index.ts)
|
Source: [`packages/core/tools/src/index.ts:131`](../../packages/core/tools/src/index.ts)
|
||||||
|
|
||||||
### `tools/execute` — waterfall
|
### `tools/execute` — waterfall
|
||||||
|
|
||||||
@@ -780,7 +780,7 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor
|
|||||||
|
|
||||||
Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
|
Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
|
||||||
|
|
||||||
Source: [`packages/core/tools/src/index.ts:89`](../../packages/core/tools/src/index.ts)
|
Source: [`packages/core/tools/src/index.ts:104`](../../packages/core/tools/src/index.ts)
|
||||||
|
|
||||||
### `tools/post-execute` — waterfall
|
### `tools/post-execute` — waterfall
|
||||||
|
|
||||||
@@ -800,7 +800,7 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts
|
|||||||
|
|
||||||
Types: [PostToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
|
Types: [PostToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
|
||||||
|
|
||||||
Source: [`packages/core/tools/src/index.ts:98`](../../packages/core/tools/src/index.ts)
|
Source: [`packages/core/tools/src/index.ts:113`](../../packages/core/tools/src/index.ts)
|
||||||
|
|
||||||
### `tools/pre-execute` — waterfall
|
### `tools/pre-execute` — waterfall
|
||||||
|
|
||||||
@@ -819,7 +819,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv
|
|||||||
|
|
||||||
Types: [PreToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
|
Types: [PreToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
|
||||||
|
|
||||||
Source: [`packages/core/tools/src/index.ts:80`](../../packages/core/tools/src/index.ts)
|
Source: [`packages/core/tools/src/index.ts:95`](../../packages/core/tools/src/index.ts)
|
||||||
|
|
||||||
### `tools/result` — emit
|
### `tools/result` — emit
|
||||||
|
|
||||||
@@ -838,7 +838,7 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained
|
|||||||
|
|
||||||
Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
|
Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
|
||||||
|
|
||||||
Source: [`packages/core/tools/src/index.ts:106`](../../packages/core/tools/src/index.ts)
|
Source: [`packages/core/tools/src/index.ts:121`](../../packages/core/tools/src/index.ts)
|
||||||
|
|
||||||
## `workflow/*`
|
## `workflow/*`
|
||||||
|
|
||||||
|
|||||||
@@ -1361,7 +1361,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>
|
|||||||
|
|
||||||
Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md)
|
Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md)
|
||||||
|
|
||||||
Source: [`packages/core/tools/src/index.ts:438`](../../packages/core/tools/src/index.ts)
|
Source: [`packages/core/tools/src/index.ts:453`](../../packages/core/tools/src/index.ts)
|
||||||
|
|
||||||
## `ctx.userInteraction` — `UserInteractionService`
|
## `ctx.userInteraction` — `UserInteractionService`
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Precisely, a data structure is **core** if either:
|
|||||||
1. it flows through the agent-loop spine — the loop holds it, derives it, streams it, or logs it on every turn (a `Message`, a `StreamChunk`, a `SessionEvent`, the `Agent` handle itself), independent of which plugins are present; **or**
|
1. it flows through the agent-loop spine — the loop holds it, derives it, streams it, or logs it on every turn (a `Message`, a `StreamChunk`, a `SessionEvent`, the `Agent` handle itself), independent of which plugins are present; **or**
|
||||||
2. it is the single headline type a plugin author writes against a pipeline — `ToolDefinition` (what every tool *is*).
|
2. it is the single headline type a plugin author writes against a pipeline — `ToolDefinition` (what every tool *is*).
|
||||||
|
|
||||||
Everything else is documented on a **sub-page**, not here. The rule that draws the line: *the type you write, hold, or receive is core; the machinery that types it, renders it, or persists it is a sub-page detail.* So `ToolDefinition` is core, but the `SchemaSpec`/`InferArgs` DSL that types it, the `ToolCallView`/`ToolResultView` render-intent vocabulary that renders it, and the `SessionPersistence` seam that stores the event log are not — they live on the sub-pages below.
|
Everything else is documented on a **sub-page**, not here. The rule that draws the line: *the type you write, hold, or receive is core; the machinery that types it, renders it, or persists it is a sub-page detail.* So `ToolDefinition` is core, but the `ValueSchemaSpec`/`ParameterSchemaSpec` inference machinery that types it, the `ToolCallView`/`ToolResultView` render-intent vocabulary that renders it, and the `SessionPersistence` seam that stores the event log are not — they live on the sub-pages below.
|
||||||
|
|
||||||
| Sub-page | Owns |
|
| Sub-page | Owns |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -490,4 +490,4 @@ type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
|
|||||||
|
|
||||||
The one pipeline-authoring type that is core: what every registered tool *is* — a model-facing `ToolSchema` plus an `execute` function and optional UI presenters. A tool author rarely constructs it by hand (the `defineTool` DSL builds it with typed args), but it is the contract the registry holds and the loop dispatches through.
|
The one pipeline-authoring type that is core: what every registered tool *is* — a model-facing `ToolSchema` plus an `execute` function and optional UI presenters. A tool author rarely constructs it by hand (the `defineTool` DSL builds it with typed args), but it is the contract the registry holds and the loop dispatches through.
|
||||||
|
|
||||||
Its full fields, the `defineTool`/`SchemaSpec`/`InferArgs` typed schema DSL, the `ToolExecution`/`ToolExecutionResult` waterfall shapes, and the tool-presentation UI vocabulary are on **[tools.md](tools.md)**.
|
Its full fields, the `defineTool`/`ValueSchemaSpec`/`ParameterSchemaSpec` typed schema DSL, the `ToolExecution`/`ToolExecutionResult` waterfall shapes, and the tool-presentation UI vocabulary are on **[tools.md](tools.md)**.
|
||||||
|
|||||||
@@ -63,11 +63,11 @@ interface SubagentStartRequest {
|
|||||||
/** Per-child agent options (model and plugin-defined extension fields). */
|
/** Per-child agent options (model and plugin-defined extension fields). */
|
||||||
readonly agentOptions?: AgentOptions
|
readonly agentOptions?: AgentOptions
|
||||||
/**
|
/**
|
||||||
* Object-rooted JSON Schema within `assertSupportedOutputSchema`'s enforced subset. Start rejects
|
* Object-rooted JSON Schema within `assertObjectJsonSchema`'s enforced subset. Start rejects
|
||||||
* unsupported schemas or providers without the capability. Data must be plain host-realm JSON;
|
* unsupported schemas or providers without the capability. Data must be plain host-realm JSON;
|
||||||
* a successful child returns the matching value as {@link SubagentResult.structured}.
|
* a successful child returns the matching value as {@link SubagentResult.structured}.
|
||||||
*/
|
*/
|
||||||
readonly outputSchema?: StructuredOutputSchema
|
readonly outputSchema?: ObjectJsonSchema
|
||||||
/**
|
/**
|
||||||
* Optional absolute delegation-depth cap for the child being started: its
|
* Optional absolute delegation-depth cap for the child being started: its
|
||||||
* computed depth must be less than or equal to this non-negative safe
|
* computed depth must be less than or equal to this non-negative safe
|
||||||
|
|||||||
@@ -57,65 +57,65 @@ interface ToolDefinition extends ToolSchema {
|
|||||||
|
|
||||||
`execute` receives `args: unknown` — a raw `ToolDefinition` validates its own input. First-party tools don't write that by hand; they use `defineTool`, which validates and narrows for them.
|
`execute` receives `args: unknown` — a raw `ToolDefinition` validates its own input. First-party tools don't write that by hand; they use `defineTool`, which validates and narrows for them.
|
||||||
|
|
||||||
## The typed schema DSL
|
## The unified JSON-value schema DSL
|
||||||
|
|
||||||
Plugin authors write per-property specs with a boolean `required: true`, and a type-level helper maps the spec to the `execute` argument type — zero casts. The DSL is *machinery that types* `ToolDefinition`; it is intentionally a sub-page detail, not core.
|
Plugin authors use one vocabulary for typed parameters and typed output values. `ValueSchemaSpec` supports `string`, `number`, `integer`, `boolean`, `null`, `array`, `object`, author-only `json`, and exact-one `oneOf`; scalar `enum` and `const` values must match their node type. An explicit object node always declares `additionalProperties: true | false`. Parameter definitions remain an implicit open object property map, with `required: true` attached to each required property.
|
||||||
|
|
||||||
Source: [`packages/core/tools/src/schema.ts`](../../packages/core/tools/src/schema.ts)
|
Source: [`packages/core/tools/src/schema.ts`](../../packages/core/tools/src/schema.ts)
|
||||||
|
|
||||||
```ts type-equiv
|
```ts type-equiv
|
||||||
/** One schema-spec property entry. */
|
/** One author-facing schema for any lossless JSON value root. */
|
||||||
interface SchemaProp {
|
type ValueSchemaSpec =
|
||||||
type: SchemaType
|
| StringValueSchemaSpec
|
||||||
/** Per-property required flag (NOT the JSON Schema top-level required array). */
|
| NumberValueSchemaSpec
|
||||||
required?: true
|
| IntegerValueSchemaSpec
|
||||||
/** Human-readable description, surfaced in the JSON Schema as well. */
|
| BooleanValueSchemaSpec
|
||||||
description?: string
|
| NullValueSchemaSpec
|
||||||
/** Enum of allowed values (strings only). */
|
| ArrayValueSchemaSpec
|
||||||
enum?: string[]
|
| ObjectValueSchemaSpec
|
||||||
/**
|
| JsonValueSchemaSpec
|
||||||
* Model-visible JSON Schema default annotation. Validation does not apply it;
|
| OneOfValueSchemaSpec
|
||||||
* dynamic tool mounts may supply it even though first-party definitions do not.
|
```
|
||||||
*/
|
|
||||||
default?: unknown
|
```ts type-equiv
|
||||||
/** Nested properties for type: 'object'. */
|
/** One implicit parameter-root property, optionally required. */
|
||||||
properties?: SchemaSpec
|
type ParameterPropertySpec = ValueSchemaSpec & { required?: true }
|
||||||
/** Items schema for type: 'array'. */
|
|
||||||
items?: SchemaProp
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```ts type-equiv
|
```ts type-equiv
|
||||||
/**
|
/**
|
||||||
* The author-facing parameter schema: a shallow map of property name to
|
* Tool parameter schema. The map itself is an implicit open object root;
|
||||||
* {@link SchemaProp}. Required-ness is a per-property boolean (`required:
|
* requiredness remains a per-property `required: true` annotation.
|
||||||
* true`), not a separate array.
|
|
||||||
*/
|
*/
|
||||||
type SchemaSpec = Record<string, SchemaProp>
|
type ParameterSchemaSpec = Record<string, ParameterPropertySpec>
|
||||||
```
|
```
|
||||||
|
|
||||||
`SchemaType` is the primitive union `'string' | 'number' | 'boolean' | 'object' | 'array'`. `InferArgs<S>` maps a `SchemaSpec` to the TS argument type — `required: true` props become required keys, everything else genuinely optional:
|
`{ type: 'json' }` infers `JsonValue` and compiles to an annotation-only unconstrained raw schema. Output roots can be objects, arrays, scalars, or null. `InferValue<S>` honors literal constraints and object openness; `InferArgs<P>` turns per-property requiredness into required and optional keys:
|
||||||
|
|
||||||
```ts type-equiv
|
```ts type-equiv
|
||||||
/**
|
/**
|
||||||
* Infer the TS argument type for a complete {@link SchemaSpec}.
|
* Infer the TypeScript value accepted by an author-facing value schema.
|
||||||
*
|
* Output schemas may therefore infer object, array, scalar, or null roots.
|
||||||
* 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 }
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
type InferArgs<S extends SchemaSpec> = Simplify<
|
type InferValue<S extends ValueSchemaSpec> =
|
||||||
& { [K in RequiredKeys<S>]: InferPropValue<S[K]> }
|
S extends StringValueSchemaSpec ? InferScalar<S, string> :
|
||||||
& { [K in Exclude<keyof S, RequiredKeys<S>>]?: InferPropValue<S[K]> }
|
S extends NumberValueSchemaSpec | IntegerValueSchemaSpec ? InferScalar<S, number> :
|
||||||
>
|
S extends BooleanValueSchemaSpec ? InferScalar<S, boolean> :
|
||||||
|
S extends NullValueSchemaSpec ? null :
|
||||||
|
S extends ArrayValueSchemaSpec
|
||||||
|
? S extends { items: infer I extends ValueSchemaSpec } ? InferValue<I>[] : JsonValue[]
|
||||||
|
: S extends ObjectValueSchemaSpec ? InferObject<S> :
|
||||||
|
S extends JsonValueSchemaSpec ? JsonValue :
|
||||||
|
S extends OneOfValueSchemaSpec ? InferValue<S['oneOf'][number]> :
|
||||||
|
never
|
||||||
```
|
```
|
||||||
|
|
||||||
`defineTool({ name, description, parameters, execute, … })` ties it together: `parameters` is a `SchemaSpec`, `execute(args, exec)` gets `args: InferArgs<typeof parameters>`, and the helper converts the spec to JSON Schema (`schemaSpecToJsonSchema`) for the wire and validates model-generated args (`validateArgs`) before the typed body runs. A mismatch throws `ToolArgsError` (`code: 'INVALID_ARGS'`), which the registry turns into an `isError` result so the model can self-correct. Why a custom DSL and not schemastery: tool parameters need JSON Schema (the LLM wire format), not validation/transformation — the lightweight DSL gives the best authoring DX with the smallest surface.
|
```ts type-equiv
|
||||||
|
/** Infer the TypeScript argument object for an implicit parameter schema. */
|
||||||
|
type InferArgs<S extends ParameterSchemaSpec> = InferProperties<S>
|
||||||
|
```
|
||||||
|
|
||||||
|
`defineTool({ name, description, parameters, execute, … })` ties parameter inference to `parameterSchemaSpecToJsonSchema()` and `validateArgs()`. `valueSchemaSpecToJsonSchema()` compiles value/output declarations through the same enforced raw subset. A parameter mismatch throws `ToolArgsError` (`INVALID_ARGS`), which the registry returns through the normal tool-error path. Raw JSON Schema remains open by default; unsupported keywords reject instead of being accepted without enforcement.
|
||||||
|
|
||||||
Registration is a trusted same-process contract. The registry borrows the typed definition as readonly input and validates only semantic requirements such as a positive finite `timeoutMs`; `schemas()` materializes the explicit model-facing projection at the model boundary so execution and presentation share one resolved definition without leaking callbacks onto the wire.
|
Registration is a trusted same-process contract. The registry borrows the typed definition as readonly input and validates only semantic requirements such as a positive finite `timeoutMs`; `schemas()` materializes the explicit model-facing projection at the model boundary so execution and presentation share one resolved definition without leaking callbacks onto the wire.
|
||||||
|
|
||||||
@@ -288,55 +288,57 @@ Call `next()` for the default or return a decision to short-circuit. Pre-policy
|
|||||||
|
|
||||||
Post-policy may replace content; a block becomes an `isError` result containing its corrective feedback. `tools/result` receives the frozen execution and result after normalization; observers cannot transform them, and observer failures are contained. Unknown and throwing tools both become structured errors (`ToolNotFoundError` maps to `UNKNOWN_TOOL`), so the call fails without ending the turn.
|
Post-policy may replace content; a block becomes an `isError` result containing its corrective feedback. `tools/result` receives the frozen execution and result after normalization; observers cannot transform them, and observer failures are contained. Unknown and throwing tools both become structured errors (`ToolNotFoundError` maps to `UNKNOWN_TOOL`), so the call fails without ending the turn.
|
||||||
|
|
||||||
## The structured-output schema subset
|
## The enforced raw JSON Schema subset
|
||||||
|
|
||||||
The vocabulary a caller uses to demand a machine-readable result from a subagent (`SubagentStartRequest.outputSchema`, [subagent.md](subagent.md#the-start-request)) or a workflow `agent()` call. It is deliberately NOT full JSON Schema: the schema travels verbatim to the model as a forced tool's `parameters`, and the produced value is validated client-side by `validateStructuredValue` — so every accepted keyword must be one the validator actually enforces, and `assertSupportedOutputSchema` rejects anything else loud (`OutputSchemaError`, listing every violation). Both walkers reason over own enumerable properties only (JSON carries nothing else) and reject non-plain objects (`Date`, `Map`) that would serialize lossily.
|
Raw schemas from subagents, workflows, MCP, and dynamic registrations use the wire-level counterpart of the author DSL. `assertSupportedJsonSchema()` accepts any JSON root, `validateJsonSchemaValue()` enforces it, and `JsonSchemaError` reports every unsupported or malformed schema path. The empty annotation-only node means unconstrained lossless JSON. `oneOf` requires at least two branches and a value must match exactly one. Consumers that still require an object root call `assertObjectJsonSchema()` and carry `ObjectJsonSchema`; this is how subagent/workflow caller-defined structured output remains object-rooted without restricting the shared vocabulary.
|
||||||
|
|
||||||
```ts type-equiv
|
```ts type-equiv
|
||||||
/** The scalar values `enum`/`const` may carry (finite numbers only). */
|
/** Scalar JSON values supported by `enum` and `const`. */
|
||||||
type StructuredScalar = string | number | boolean | null
|
type JsonSchemaScalar = string | number | boolean | null
|
||||||
```
|
```
|
||||||
|
|
||||||
```ts type-equiv
|
```ts type-equiv
|
||||||
/** The `type` keywords the subset accepts. */
|
/** Single-type keywords accepted by the enforced subset. */
|
||||||
type StructuredSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null'
|
type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null'
|
||||||
```
|
```
|
||||||
|
|
||||||
```ts type-equiv
|
```ts type-equiv
|
||||||
/**
|
/**
|
||||||
* One node of the structured-output schema subset. Recursive via `properties`
|
* One raw JSON Schema node in the enforced subset. The optional fields express
|
||||||
* and `items`; see the module doc for the exact keyword semantics.
|
* the external wire shape; {@link assertSupportedJsonSchema} rejects invalid
|
||||||
|
* combinations before a caller treats the node as trusted.
|
||||||
*/
|
*/
|
||||||
interface StructuredSchemaNode {
|
interface JsonSchemaNode {
|
||||||
type: StructuredSchemaType
|
/** 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). */
|
/** Nested property schemas (`type: 'object'` only). */
|
||||||
properties?: Record<string, StructuredSchemaNode>
|
properties?: Record<string, JsonSchemaNode>
|
||||||
/** Required property names; each must appear in `properties`. */
|
/** Required property names; each must appear in `properties`. */
|
||||||
required?: string[]
|
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
|
additionalProperties?: boolean
|
||||||
/** Item schema (`type: 'array'` only); absent ⇒ any JSON items. */
|
/** Item schema (`type: 'array'` only); absent accepts any JSON item. */
|
||||||
items?: StructuredSchemaNode
|
items?: JsonSchemaNode
|
||||||
/** Allowed values (scalar types only). */
|
/** Allowed values for a scalar node. */
|
||||||
enum?: StructuredScalar[]
|
enum?: JsonSchemaScalar[]
|
||||||
/** The single allowed value (scalar types only). */
|
/** The single allowed value for a scalar node. */
|
||||||
const?: StructuredScalar
|
const?: JsonSchemaScalar
|
||||||
/** Annotation, ignored for validation. */
|
/** Annotation, ignored for validation. */
|
||||||
description?: string
|
description?: string
|
||||||
/** Annotation, ignored for validation. */
|
/** Annotation, ignored for validation. */
|
||||||
title?: string
|
title?: string
|
||||||
/** Annotation, ignored for validation (must still be JSON data). */
|
/** Annotation, ignored for validation but required to be lossless JSON. */
|
||||||
default?: unknown
|
default?: JsonValue
|
||||||
/** Annotation, ignored for validation (must still be JSON data). */
|
/** Annotation, ignored for validation but required to be lossless JSON. */
|
||||||
examples?: unknown
|
examples?: JsonValue
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
A schema is an object-rooted node (`enum`/`const` are scalar-only; `description`/`title`/`default`/`examples` are annotations, allowed and ignored but still required to be JSON data — they ride the wire):
|
|
||||||
|
|
||||||
```ts type-equiv
|
```ts type-equiv
|
||||||
/** A structured-output schema: an OBJECT-rooted {@link StructuredSchemaNode}. */
|
/** A consumer-constrained object-rooted schema. */
|
||||||
type StructuredOutputSchema = StructuredSchemaNode & { type: 'object' }
|
type ObjectJsonSchema = JsonSchemaNode & { type: 'object' }
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tool-presentation UI vocabulary
|
## Tool-presentation UI vocabulary
|
||||||
|
|||||||
@@ -41,11 +41,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
|||||||
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:130`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
|
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:130`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
|
||||||
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:27`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`acp`](../packages/ui/acp) |
|
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:27`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`acp`](../packages/ui/acp) |
|
||||||
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:33`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
|
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:33`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
|
||||||
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:116`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
|
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:131`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
|
||||||
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:89`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) |
|
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:104`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) |
|
||||||
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:98`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`workspace-context`](../packages/context/workspace-context) |
|
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`workspace-context`](../packages/context/workspace-context) |
|
||||||
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:80`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:95`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||||
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:106`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) |
|
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:121`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) |
|
||||||
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||||
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||||
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ Ask the user a concise question when you need confirmation, a choice, or missing
|
|||||||
"description": "Questions to ask the user before continuing.",
|
"description": "Questions to ask the user before continuing.",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -63,6 +64,7 @@ Ask the user a concise question when you need confirmation, a choice, or missing
|
|||||||
"description": "Optional choices to show the user. If you recommend one, put it first and append \"(Recommended)\" to that label.",
|
"description": "Optional choices to show the user. If you recommend one, put it first and append \"(Recommended)\" to that label.",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"label": {
|
"label": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -201,7 +203,7 @@ Source: [`packages/cordis/tool-cordis/src/index.ts`](../packages/cordis/tool-cor
|
|||||||
|
|
||||||
### `cordis_mount`
|
### `cordis_mount`
|
||||||
|
|
||||||
Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). 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) { … } }))` 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 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. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.
|
Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). 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) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. 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 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 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. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -659,6 +661,7 @@ Record and update a structured task list for the current work. Send the ENTIRE l
|
|||||||
"description": "The COMPLETE task list, replacing any previous list.",
|
"description": "The COMPLETE task list, replacing any previous list.",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"content": {
|
"content": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -720,6 +723,7 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim
|
|||||||
"meta": {
|
"meta": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "The workflow identity block (plain JSON — never code).",
|
"description": "The workflow identity block (plain JSON — never code).",
|
||||||
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -738,6 +742,7 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim
|
|||||||
"description": "Optional phase declarations matched by phase() calls.",
|
"description": "Optional phase declarations matched by phase() calls.",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"title": {
|
"title": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -769,7 +774,8 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim
|
|||||||
},
|
},
|
||||||
"args": {
|
"args": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."
|
"description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).",
|
||||||
|
"additionalProperties": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||||
# after editing either side, bring the other along and re-record with:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write
|
# pnpm run verify-translation-pairing --write
|
||||||
tool.md: 416733bcb584fa5303a8b3ba5e6e904302e7f992
|
tool.md: 17adbfc5f7eb804856dfe39b4d2b4a65374b6414
|
||||||
tool.zh.md: fce9a7d9b973853c8b4fb9ae2c034e749d8da999
|
tool.zh.md: 8857e16ca836dfa5b158a68581bd3c715dcb1ac5
|
||||||
|
|||||||
@@ -37,10 +37,11 @@ export function apply(ctx: Context) {
|
|||||||
```ts
|
```ts
|
||||||
export const parameters = {
|
export const parameters = {
|
||||||
path: { type: 'string', required: true },
|
path: { type: 'string', required: true },
|
||||||
limit: { type: 'number' },
|
limit: { type: 'integer' },
|
||||||
recursive: { type: 'boolean' },
|
recursive: { type: 'boolean' },
|
||||||
|
parent: { type: 'null' },
|
||||||
}
|
}
|
||||||
// Inferred type: { path: string; limit?: number; recursive?: boolean }
|
// Inferred type: { path: string; limit?: number; recursive?: boolean; parent?: null }
|
||||||
```
|
```
|
||||||
|
|
||||||
### Enums
|
### Enums
|
||||||
@@ -49,7 +50,7 @@ export const parameters = {
|
|||||||
export const parameters = {
|
export const parameters = {
|
||||||
mode: { type: 'string', required: true, enum: ['read', 'write', 'append'] },
|
mode: { type: 'string', required: true, enum: ['read', 'write', 'append'] },
|
||||||
}
|
}
|
||||||
// Inferred type: { mode: string } (enum values are validated at runtime)
|
// Inferred type: { mode: 'read' | 'write' | 'append' }
|
||||||
```
|
```
|
||||||
|
|
||||||
### Nested objects
|
### Nested objects
|
||||||
@@ -58,13 +59,14 @@ export const parameters = {
|
|||||||
export const parameters = {
|
export const parameters = {
|
||||||
options: {
|
options: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: true,
|
||||||
properties: {
|
properties: {
|
||||||
timeout: { type: 'number' },
|
timeout: { type: 'number' },
|
||||||
retries: { type: 'number' },
|
retries: { type: 'number' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
// Inferred type: { options?: { timeout?: number; retries?: number } }
|
// The declared fields are inferred; additional JSON-valued keys are allowed.
|
||||||
```
|
```
|
||||||
|
|
||||||
### Arrays
|
### Arrays
|
||||||
@@ -83,12 +85,16 @@ export const parameters = {
|
|||||||
|
|
||||||
| Field | Type | Meaning |
|
| Field | Type | Meaning |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `type` | `'string' \| 'number' \| 'boolean' \| 'object' \| 'array'` | Value type |
|
| `type` | `'string' \| 'number' \| 'integer' \| 'boolean' \| 'null' \| 'object' \| 'array' \| 'json'` | Value type; `json` accepts any lossless JSON value |
|
||||||
| `required` | `true` | Marks the property required and affects inference |
|
| `required` | `true` | Marks the property required and affects inference |
|
||||||
| `description` | `string` | Description sent to the model |
|
| `description` | `string` | Description sent to the model |
|
||||||
| `enum` | `string[]` | Allowed string values |
|
| `enum` / `const` | matching scalar values | Allowed literal values, checked at author and runtime boundaries |
|
||||||
| `properties` | `SchemaSpec` | Nested properties for an object |
|
| `properties` | `ParameterSchemaSpec` | Nested properties for an object |
|
||||||
| `items` | `SchemaProp` | Element schema for an array |
|
| `additionalProperties` | `true \| false` | Required on every explicit object node |
|
||||||
|
| `items` | `ValueSchemaSpec` | Element schema for an array |
|
||||||
|
| `oneOf` | at least two `ValueSchemaSpec` branches | Requires exactly one matching branch; used instead of `type` |
|
||||||
|
|
||||||
|
The outer `parameters` map is an implicit open object. Explicit nested objects choose their openness; raw JSON Schema registered without `defineTool` keeps JSON Schema's open-by-default behavior.
|
||||||
|
|
||||||
## The execute function
|
## The execute function
|
||||||
|
|
||||||
|
|||||||
@@ -37,10 +37,11 @@ export function apply(ctx: Context) {
|
|||||||
```ts
|
```ts
|
||||||
export const parameters = {
|
export const parameters = {
|
||||||
path: { type: 'string', required: true },
|
path: { type: 'string', required: true },
|
||||||
limit: { type: 'number' },
|
limit: { type: 'integer' },
|
||||||
recursive: { type: 'boolean' },
|
recursive: { type: 'boolean' },
|
||||||
|
parent: { type: 'null' },
|
||||||
}
|
}
|
||||||
// Inferred type: { path: string; limit?: number; recursive?: boolean }
|
// Inferred type: { path: string; limit?: number; recursive?: boolean; parent?: null }
|
||||||
```
|
```
|
||||||
|
|
||||||
### 枚举
|
### 枚举
|
||||||
@@ -49,7 +50,7 @@ export const parameters = {
|
|||||||
export const parameters = {
|
export const parameters = {
|
||||||
mode: { type: 'string', required: true, enum: ['read', 'write', 'append'] },
|
mode: { type: 'string', required: true, enum: ['read', 'write', 'append'] },
|
||||||
}
|
}
|
||||||
// Inferred type: { mode: string } (enum values are validated at runtime)
|
// Inferred type: { mode: 'read' | 'write' | 'append' }
|
||||||
```
|
```
|
||||||
|
|
||||||
### 嵌套对象
|
### 嵌套对象
|
||||||
@@ -58,13 +59,14 @@ export const parameters = {
|
|||||||
export const parameters = {
|
export const parameters = {
|
||||||
options: {
|
options: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: true,
|
||||||
properties: {
|
properties: {
|
||||||
timeout: { type: 'number' },
|
timeout: { type: 'number' },
|
||||||
retries: { type: 'number' },
|
retries: { type: 'number' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
// Inferred type: { options?: { timeout?: number; retries?: number } }
|
// The declared fields are inferred; additional JSON-valued keys are allowed.
|
||||||
```
|
```
|
||||||
|
|
||||||
### 数组
|
### 数组
|
||||||
@@ -83,12 +85,16 @@ export const parameters = {
|
|||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `type` | `'string' \| 'number' \| 'boolean' \| 'object' \| 'array'` | 值类型 |
|
| `type` | `'string' \| 'number' \| 'integer' \| 'boolean' \| 'null' \| 'object' \| 'array' \| 'json'` | 值类型;`json` 接受任意无损 JSON 值 |
|
||||||
| `required` | `true` | 标记为必填(影响类型推导) |
|
| `required` | `true` | 标记为必填(影响类型推导) |
|
||||||
| `description` | `string` | 发送给模型的描述 |
|
| `description` | `string` | 发送给模型的描述 |
|
||||||
| `enum` | `string[]` | 允许的枚举值 |
|
| `enum` / `const` | 匹配类型的标量值 | 允许的字面量值,在编写和运行时边界校验 |
|
||||||
| `properties` | `SchemaSpec` | 嵌套属性(type 为 object 时) |
|
| `properties` | `ParameterSchemaSpec` | 对象的嵌套属性 |
|
||||||
| `items` | `SchemaProp` | 数组元素 schema(type 为 array 时) |
|
| `additionalProperties` | `true \| false` | 每个显式对象节点都必须声明 |
|
||||||
|
| `items` | `ValueSchemaSpec` | 数组的元素 schema |
|
||||||
|
| `oneOf` | 至少两个 `ValueSchemaSpec` 分支 | 要求恰好匹配一个分支;代替 `type` 使用 |
|
||||||
|
|
||||||
|
外层 `parameters` 映射是一个隐式的开放对象。显式嵌套对象需自行选择是否开放;不通过 `defineTool` 注册的原始 JSON Schema 保持 JSON Schema 的默认开放语义。
|
||||||
|
|
||||||
## execute 函数
|
## execute 函数
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ Pass `run_code` the body of an async TypeScript function (erasable syntax only
|
|||||||
The available tools:
|
The available tools:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
|
||||||
|
|
||||||
declare const tools: {
|
declare const tools: {
|
||||||
/** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */
|
/** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */
|
||||||
bash(args: {
|
bash(args: {
|
||||||
@@ -53,31 +55,31 @@ declare const tools: {
|
|||||||
sandbox_permissions?: "workspace-write" | "danger-full-access";
|
sandbox_permissions?: "workspace-write" | "danger-full-access";
|
||||||
/** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */
|
/** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */
|
||||||
justification?: string;
|
justification?: string;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:"api"` or `what:"events"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */
|
/** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:"api"` or `what:"events"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */
|
||||||
cordis_inspect(args: {
|
cordis_inspect(args: {
|
||||||
/** Limit the report to one section. Omit for all sections. */
|
/** Limit the report to one section. Omit for all sections. */
|
||||||
what?: "services" | "plugins" | "tools" | "dynamic" | "api" | "events";
|
what?: "services" | "plugins" | "tools" | "dynamic" | "api" | "events";
|
||||||
/** Exact service key or event name whose original JSDoc to include; valid only with what:"api" or what:"events". */
|
/** Exact service key or event name whose original JSDoc to include; valid only with what:"api" or what:"events". */
|
||||||
name?: string;
|
name?: string;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). 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) { … } }))` 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 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. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */
|
/** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). 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) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. 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 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 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. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */
|
||||||
cordis_mount(args: {
|
cordis_mount(args: {
|
||||||
/** Body of an async JS function; must `return` the plugin to mount. */
|
/** Body of an async JS function; must `return` the plugin to mount. */
|
||||||
code: string;
|
code: string;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */
|
/** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */
|
||||||
cordis_unmount(args: {
|
cordis_unmount(args: {
|
||||||
/** The dynamic mount id returned by cordis_mount (e.g. "dyn-1"). */
|
/** The dynamic mount id returned by cordis_mount (e.g. "dyn-1"). */
|
||||||
id: string;
|
id: string;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. */
|
/** Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. */
|
||||||
create_goal(args: {
|
create_goal(args: {
|
||||||
/** The concrete completion objective inferred from the direct human request. */
|
/** The concrete completion objective inferred from the direct human request. */
|
||||||
objective: string;
|
objective: string;
|
||||||
/** Optional positive safe-integer limit on automatic continuation rounds. */
|
/** Optional positive safe-integer limit on automatic continuation rounds. */
|
||||||
max_goal_rounds?: number;
|
max_goal_rounds?: number;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Edit an existing UTF-8 text file by replacing literal text. */
|
/** Edit an existing UTF-8 text file by replacing literal text. */
|
||||||
edit(args: {
|
edit(args: {
|
||||||
/** Path to edit, resolved by the filesystem backend. */
|
/** Path to edit, resolved by the filesystem backend. */
|
||||||
@@ -92,16 +94,16 @@ declare const tools: {
|
|||||||
sandbox_permissions?: "workspace-write" | "danger-full-access";
|
sandbox_permissions?: "workspace-write" | "danger-full-access";
|
||||||
/** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */
|
/** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */
|
||||||
justification?: string;
|
justification?: string;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */
|
/** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */
|
||||||
get_goal(args: Record<string, unknown>): Promise<string>;
|
get_goal(args: Record<string, JsonValue>): Promise<string>;
|
||||||
/** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */
|
/** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */
|
||||||
ralph(args: {
|
ralph(args: {
|
||||||
/** The immutable completion objective for every fresh Ralph round. */
|
/** The immutable completion objective for every fresh Ralph round. */
|
||||||
objective: string;
|
objective: string;
|
||||||
/** Optional positive safe-integer round cap, bounded by the deployment ceiling. */
|
/** Optional positive safe-integer round cap, bounded by the deployment ceiling. */
|
||||||
maxRounds?: number;
|
maxRounds?: number;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Read a UTF-8 text file and return line-numbered content. */
|
/** Read a UTF-8 text file and return line-numbered content. */
|
||||||
read(args: {
|
read(args: {
|
||||||
/** Path to read, resolved by the filesystem backend. */
|
/** Path to read, resolved by the filesystem backend. */
|
||||||
@@ -110,12 +112,12 @@ declare const tools: {
|
|||||||
offset?: number;
|
offset?: number;
|
||||||
/** Maximum number of lines to return. Defaults to 2000. */
|
/** Maximum number of lines to return. Defaults to 2000. */
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */
|
/** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */
|
||||||
skill(args: {
|
skill(args: {
|
||||||
/** The exact skill name from the available skills list. */
|
/** The exact skill name from the available skills list. */
|
||||||
name: string;
|
name: string;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */
|
/** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */
|
||||||
subagent(args: {
|
subagent(args: {
|
||||||
/** A short (3-5 word) description of the delegated task, for display. */
|
/** A short (3-5 word) description of the delegated task, for display. */
|
||||||
@@ -124,7 +126,7 @@ declare const tools: {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
/** Run as a background task and return its id; collect with task_output or stop with task_kill. */
|
/** Run as a background task and return its id; collect with task_output or stop with task_kill. */
|
||||||
run_in_background?: boolean;
|
run_in_background?: boolean;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */
|
/** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */
|
||||||
subagent_fork(args: {
|
subagent_fork(args: {
|
||||||
/** A short (3-5 word) description of the delegated task, for display. */
|
/** A short (3-5 word) description of the delegated task, for display. */
|
||||||
@@ -133,16 +135,16 @@ declare const tools: {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
/** Run as a background task and return its id; collect with task_output or stop with task_kill. */
|
/** Run as a background task and return its id; collect with task_output or stop with task_kill. */
|
||||||
run_in_background?: boolean;
|
run_in_background?: boolean;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */
|
/** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */
|
||||||
task_kill(args: {
|
task_kill(args: {
|
||||||
/** Task id returned by the tool that started the background work. */
|
/** Task id returned by the tool that started the background work. */
|
||||||
task_id: string;
|
task_id: string;
|
||||||
/** Optional short reason, recorded in the log and forwarded to the task. */
|
/** Optional short reason, recorded in the log and forwarded to the task. */
|
||||||
reason?: string;
|
reason?: string;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** List your background tasks (running and finished) with their ids, kinds, and statuses. */
|
/** List your background tasks (running and finished) with their ids, kinds, and statuses. */
|
||||||
task_list(args: Record<string, unknown>): Promise<string>;
|
task_list(args: Record<string, JsonValue>): Promise<string>;
|
||||||
/** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */
|
/** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */
|
||||||
task_output(args: {
|
task_output(args: {
|
||||||
/** Task id returned by the tool that started the background work. */
|
/** Task id returned by the tool that started the background work. */
|
||||||
@@ -151,7 +153,7 @@ declare const tools: {
|
|||||||
wait?: boolean;
|
wait?: boolean;
|
||||||
/** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */
|
/** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */
|
||||||
timeout_ms?: number;
|
timeout_ms?: number;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */
|
/** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */
|
||||||
todo_write(args: {
|
todo_write(args: {
|
||||||
/** The COMPLETE task list, replacing any previous list. */
|
/** The COMPLETE task list, replacing any previous list. */
|
||||||
@@ -160,8 +162,8 @@ declare const tools: {
|
|||||||
content: string;
|
content: string;
|
||||||
/** pending (not started) | in_progress (now) | completed (done). */
|
/** pending (not started) | in_progress (now) | completed (done). */
|
||||||
status: "pending" | "in_progress" | "completed";
|
status: "pending" | "in_progress" | "completed";
|
||||||
})[];
|
} & Record<string, JsonValue>)[];
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */
|
/** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */
|
||||||
update_goal(args: {
|
update_goal(args: {
|
||||||
/** Exact id returned by get_goal. */
|
/** Exact id returned by get_goal. */
|
||||||
@@ -176,7 +178,7 @@ declare const tools: {
|
|||||||
max_goal_rounds?: number;
|
max_goal_rounds?: number;
|
||||||
/** Concrete blocking condition; required only with action blocked. */
|
/** Concrete blocking condition; required only with action blocked. */
|
||||||
blocked_reason?: string;
|
blocked_reason?: string;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */
|
/** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */
|
||||||
workflow(args: {
|
workflow(args: {
|
||||||
/** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`). */
|
/** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`). */
|
||||||
@@ -190,7 +192,7 @@ declare const tools: {
|
|||||||
/** Optional guidance on when this workflow applies. */
|
/** Optional guidance on when this workflow applies. */
|
||||||
whenToUse?: string;
|
whenToUse?: string;
|
||||||
/** Optional phase declarations matched by phase() calls. */
|
/** Optional phase declarations matched by phase() calls. */
|
||||||
phases?: {
|
phases?: ({
|
||||||
/** The phase title phase() calls match by exact string. */
|
/** The phase title phase() calls match by exact string. */
|
||||||
title: string;
|
title: string;
|
||||||
/** Optional one-line description of the phase. */
|
/** Optional one-line description of the phase. */
|
||||||
@@ -199,11 +201,11 @@ declare const tools: {
|
|||||||
provider?: string;
|
provider?: string;
|
||||||
/** Optional model override this phase is expected to use. */
|
/** Optional model override this phase is expected to use. */
|
||||||
model?: string;
|
model?: string;
|
||||||
}[];
|
} & Record<string, JsonValue>)[];
|
||||||
};
|
} & Record<string, JsonValue>;
|
||||||
/** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */
|
/** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */
|
||||||
args?: Record<string, unknown>;
|
args?: Record<string, JsonValue>;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Create or fully replace a UTF-8 text file. */
|
/** Create or fully replace a UTF-8 text file. */
|
||||||
write(args: {
|
write(args: {
|
||||||
/** Path to write, resolved by the filesystem backend. */
|
/** Path to write, resolved by the filesystem backend. */
|
||||||
@@ -214,6 +216,6 @@ declare const tools: {
|
|||||||
sandbox_permissions?: "workspace-write" | "danger-full-access";
|
sandbox_permissions?: "workspace-write" | "danger-full-access";
|
||||||
/** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */
|
/** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */
|
||||||
justification?: string;
|
justification?: string;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "cordis_mount",
|
"name": "cordis_mount",
|
||||||
"description": "Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). 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) { … } }))` 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 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. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.",
|
"description": "Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). 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) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. 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 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 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. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -361,6 +361,7 @@
|
|||||||
"description": "The COMPLETE task list, replacing any previous list.",
|
"description": "The COMPLETE task list, replacing any previous list.",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"content": {
|
"content": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -446,6 +447,7 @@
|
|||||||
"meta": {
|
"meta": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "The workflow identity block (plain JSON — never code).",
|
"description": "The workflow identity block (plain JSON — never code).",
|
||||||
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -464,6 +466,7 @@
|
|||||||
"description": "Optional phase declarations matched by phase() calls.",
|
"description": "Optional phase declarations matched by phase() calls.",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"title": {
|
"title": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -495,7 +498,8 @@
|
|||||||
},
|
},
|
||||||
"args": {
|
"args": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."
|
"description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).",
|
||||||
|
"additionalProperties": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ Pass `run_code` the body of an async TypeScript function (erasable syntax only
|
|||||||
The available tools:
|
The available tools:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
|
||||||
|
|
||||||
declare const tools: {
|
declare const tools: {
|
||||||
/** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */
|
/** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */
|
||||||
bash(args: {
|
bash(args: {
|
||||||
@@ -53,14 +55,14 @@ declare const tools: {
|
|||||||
sandbox_permissions?: "workspace-write" | "danger-full-access";
|
sandbox_permissions?: "workspace-write" | "danger-full-access";
|
||||||
/** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */
|
/** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */
|
||||||
justification?: string;
|
justification?: string;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. */
|
/** Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. */
|
||||||
create_goal(args: {
|
create_goal(args: {
|
||||||
/** The concrete completion objective inferred from the direct human request. */
|
/** The concrete completion objective inferred from the direct human request. */
|
||||||
objective: string;
|
objective: string;
|
||||||
/** Optional positive safe-integer limit on automatic continuation rounds. */
|
/** Optional positive safe-integer limit on automatic continuation rounds. */
|
||||||
max_goal_rounds?: number;
|
max_goal_rounds?: number;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Edit an existing UTF-8 text file by replacing literal text. */
|
/** Edit an existing UTF-8 text file by replacing literal text. */
|
||||||
edit(args: {
|
edit(args: {
|
||||||
/** Path to edit, resolved by the filesystem backend. */
|
/** Path to edit, resolved by the filesystem backend. */
|
||||||
@@ -75,16 +77,16 @@ declare const tools: {
|
|||||||
sandbox_permissions?: "workspace-write" | "danger-full-access";
|
sandbox_permissions?: "workspace-write" | "danger-full-access";
|
||||||
/** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */
|
/** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */
|
||||||
justification?: string;
|
justification?: string;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */
|
/** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */
|
||||||
get_goal(args: Record<string, unknown>): Promise<string>;
|
get_goal(args: Record<string, JsonValue>): Promise<string>;
|
||||||
/** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */
|
/** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */
|
||||||
ralph(args: {
|
ralph(args: {
|
||||||
/** The immutable completion objective for every fresh Ralph round. */
|
/** The immutable completion objective for every fresh Ralph round. */
|
||||||
objective: string;
|
objective: string;
|
||||||
/** Optional positive safe-integer round cap, bounded by the deployment ceiling. */
|
/** Optional positive safe-integer round cap, bounded by the deployment ceiling. */
|
||||||
maxRounds?: number;
|
maxRounds?: number;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Read a UTF-8 text file and return line-numbered content. */
|
/** Read a UTF-8 text file and return line-numbered content. */
|
||||||
read(args: {
|
read(args: {
|
||||||
/** Path to read, resolved by the filesystem backend. */
|
/** Path to read, resolved by the filesystem backend. */
|
||||||
@@ -93,12 +95,12 @@ declare const tools: {
|
|||||||
offset?: number;
|
offset?: number;
|
||||||
/** Maximum number of lines to return. Defaults to 2000. */
|
/** Maximum number of lines to return. Defaults to 2000. */
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */
|
/** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */
|
||||||
skill(args: {
|
skill(args: {
|
||||||
/** The exact skill name from the available skills list. */
|
/** The exact skill name from the available skills list. */
|
||||||
name: string;
|
name: string;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */
|
/** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */
|
||||||
subagent(args: {
|
subagent(args: {
|
||||||
/** A short (3-5 word) description of the delegated task, for display. */
|
/** A short (3-5 word) description of the delegated task, for display. */
|
||||||
@@ -107,7 +109,7 @@ declare const tools: {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
/** Run as a background task and return its id; collect with task_output or stop with task_kill. */
|
/** Run as a background task and return its id; collect with task_output or stop with task_kill. */
|
||||||
run_in_background?: boolean;
|
run_in_background?: boolean;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */
|
/** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */
|
||||||
subagent_fork(args: {
|
subagent_fork(args: {
|
||||||
/** A short (3-5 word) description of the delegated task, for display. */
|
/** A short (3-5 word) description of the delegated task, for display. */
|
||||||
@@ -116,16 +118,16 @@ declare const tools: {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
/** Run as a background task and return its id; collect with task_output or stop with task_kill. */
|
/** Run as a background task and return its id; collect with task_output or stop with task_kill. */
|
||||||
run_in_background?: boolean;
|
run_in_background?: boolean;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */
|
/** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */
|
||||||
task_kill(args: {
|
task_kill(args: {
|
||||||
/** Task id returned by the tool that started the background work. */
|
/** Task id returned by the tool that started the background work. */
|
||||||
task_id: string;
|
task_id: string;
|
||||||
/** Optional short reason, recorded in the log and forwarded to the task. */
|
/** Optional short reason, recorded in the log and forwarded to the task. */
|
||||||
reason?: string;
|
reason?: string;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** List your background tasks (running and finished) with their ids, kinds, and statuses. */
|
/** List your background tasks (running and finished) with their ids, kinds, and statuses. */
|
||||||
task_list(args: Record<string, unknown>): Promise<string>;
|
task_list(args: Record<string, JsonValue>): Promise<string>;
|
||||||
/** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */
|
/** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */
|
||||||
task_output(args: {
|
task_output(args: {
|
||||||
/** Task id returned by the tool that started the background work. */
|
/** Task id returned by the tool that started the background work. */
|
||||||
@@ -134,7 +136,7 @@ declare const tools: {
|
|||||||
wait?: boolean;
|
wait?: boolean;
|
||||||
/** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */
|
/** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */
|
||||||
timeout_ms?: number;
|
timeout_ms?: number;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */
|
/** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */
|
||||||
todo_write(args: {
|
todo_write(args: {
|
||||||
/** The COMPLETE task list, replacing any previous list. */
|
/** The COMPLETE task list, replacing any previous list. */
|
||||||
@@ -143,8 +145,8 @@ declare const tools: {
|
|||||||
content: string;
|
content: string;
|
||||||
/** pending (not started) | in_progress (now) | completed (done). */
|
/** pending (not started) | in_progress (now) | completed (done). */
|
||||||
status: "pending" | "in_progress" | "completed";
|
status: "pending" | "in_progress" | "completed";
|
||||||
})[];
|
} & Record<string, JsonValue>)[];
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */
|
/** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */
|
||||||
update_goal(args: {
|
update_goal(args: {
|
||||||
/** Exact id returned by get_goal. */
|
/** Exact id returned by get_goal. */
|
||||||
@@ -159,7 +161,7 @@ declare const tools: {
|
|||||||
max_goal_rounds?: number;
|
max_goal_rounds?: number;
|
||||||
/** Concrete blocking condition; required only with action blocked. */
|
/** Concrete blocking condition; required only with action blocked. */
|
||||||
blocked_reason?: string;
|
blocked_reason?: string;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */
|
/** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */
|
||||||
workflow(args: {
|
workflow(args: {
|
||||||
/** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`). */
|
/** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`). */
|
||||||
@@ -173,7 +175,7 @@ declare const tools: {
|
|||||||
/** Optional guidance on when this workflow applies. */
|
/** Optional guidance on when this workflow applies. */
|
||||||
whenToUse?: string;
|
whenToUse?: string;
|
||||||
/** Optional phase declarations matched by phase() calls. */
|
/** Optional phase declarations matched by phase() calls. */
|
||||||
phases?: {
|
phases?: ({
|
||||||
/** The phase title phase() calls match by exact string. */
|
/** The phase title phase() calls match by exact string. */
|
||||||
title: string;
|
title: string;
|
||||||
/** Optional one-line description of the phase. */
|
/** Optional one-line description of the phase. */
|
||||||
@@ -182,11 +184,11 @@ declare const tools: {
|
|||||||
provider?: string;
|
provider?: string;
|
||||||
/** Optional model override this phase is expected to use. */
|
/** Optional model override this phase is expected to use. */
|
||||||
model?: string;
|
model?: string;
|
||||||
}[];
|
} & Record<string, JsonValue>)[];
|
||||||
};
|
} & Record<string, JsonValue>;
|
||||||
/** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */
|
/** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */
|
||||||
args?: Record<string, unknown>;
|
args?: Record<string, JsonValue>;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Create or fully replace a UTF-8 text file. */
|
/** Create or fully replace a UTF-8 text file. */
|
||||||
write(args: {
|
write(args: {
|
||||||
/** Path to write, resolved by the filesystem backend. */
|
/** Path to write, resolved by the filesystem backend. */
|
||||||
@@ -197,6 +199,6 @@ declare const tools: {
|
|||||||
sandbox_permissions?: "workspace-write" | "danger-full-access";
|
sandbox_permissions?: "workspace-write" | "danger-full-access";
|
||||||
/** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */
|
/** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */
|
||||||
justification?: string;
|
justification?: string;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -304,6 +304,7 @@
|
|||||||
"description": "The COMPLETE task list, replacing any previous list.",
|
"description": "The COMPLETE task list, replacing any previous list.",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"content": {
|
"content": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -389,6 +390,7 @@
|
|||||||
"meta": {
|
"meta": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "The workflow identity block (plain JSON — never code).",
|
"description": "The workflow identity block (plain JSON — never code).",
|
||||||
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -407,6 +409,7 @@
|
|||||||
"description": "Optional phase declarations matched by phase() calls.",
|
"description": "Optional phase declarations matched by phase() calls.",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"title": {
|
"title": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -438,7 +441,8 @@
|
|||||||
},
|
},
|
||||||
"args": {
|
"args": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."
|
"description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).",
|
||||||
|
"additionalProperties": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ Pass `run_code` the body of an async TypeScript function (erasable syntax only
|
|||||||
The available tools:
|
The available tools:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
|
||||||
|
|
||||||
declare const tools: {
|
declare const tools: {
|
||||||
/** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */
|
/** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */
|
||||||
bash(args: {
|
bash(args: {
|
||||||
@@ -53,14 +55,14 @@ declare const tools: {
|
|||||||
sandbox_permissions?: "workspace-write" | "danger-full-access";
|
sandbox_permissions?: "workspace-write" | "danger-full-access";
|
||||||
/** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */
|
/** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */
|
||||||
justification?: string;
|
justification?: string;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. */
|
/** Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. */
|
||||||
create_goal(args: {
|
create_goal(args: {
|
||||||
/** The concrete completion objective inferred from the direct human request. */
|
/** The concrete completion objective inferred from the direct human request. */
|
||||||
objective: string;
|
objective: string;
|
||||||
/** Optional positive safe-integer limit on automatic continuation rounds. */
|
/** Optional positive safe-integer limit on automatic continuation rounds. */
|
||||||
max_goal_rounds?: number;
|
max_goal_rounds?: number;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Edit an existing UTF-8 text file by replacing literal text. */
|
/** Edit an existing UTF-8 text file by replacing literal text. */
|
||||||
edit(args: {
|
edit(args: {
|
||||||
/** Path to edit, resolved by the filesystem backend. */
|
/** Path to edit, resolved by the filesystem backend. */
|
||||||
@@ -75,16 +77,16 @@ declare const tools: {
|
|||||||
sandbox_permissions?: "workspace-write" | "danger-full-access";
|
sandbox_permissions?: "workspace-write" | "danger-full-access";
|
||||||
/** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */
|
/** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */
|
||||||
justification?: string;
|
justification?: string;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */
|
/** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */
|
||||||
get_goal(args: Record<string, unknown>): Promise<string>;
|
get_goal(args: Record<string, JsonValue>): Promise<string>;
|
||||||
/** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */
|
/** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */
|
||||||
ralph(args: {
|
ralph(args: {
|
||||||
/** The immutable completion objective for every fresh Ralph round. */
|
/** The immutable completion objective for every fresh Ralph round. */
|
||||||
objective: string;
|
objective: string;
|
||||||
/** Optional positive safe-integer round cap, bounded by the deployment ceiling. */
|
/** Optional positive safe-integer round cap, bounded by the deployment ceiling. */
|
||||||
maxRounds?: number;
|
maxRounds?: number;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Read a UTF-8 text file and return line-numbered content. */
|
/** Read a UTF-8 text file and return line-numbered content. */
|
||||||
read(args: {
|
read(args: {
|
||||||
/** Path to read, resolved by the filesystem backend. */
|
/** Path to read, resolved by the filesystem backend. */
|
||||||
@@ -93,12 +95,12 @@ declare const tools: {
|
|||||||
offset?: number;
|
offset?: number;
|
||||||
/** Maximum number of lines to return. Defaults to 2000. */
|
/** Maximum number of lines to return. Defaults to 2000. */
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */
|
/** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */
|
||||||
skill(args: {
|
skill(args: {
|
||||||
/** The exact skill name from the available skills list. */
|
/** The exact skill name from the available skills list. */
|
||||||
name: string;
|
name: string;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */
|
/** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */
|
||||||
subagent(args: {
|
subagent(args: {
|
||||||
/** A short (3-5 word) description of the delegated task, for display. */
|
/** A short (3-5 word) description of the delegated task, for display. */
|
||||||
@@ -107,7 +109,7 @@ declare const tools: {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
/** Run as a background task and return its id; collect with task_output or stop with task_kill. */
|
/** Run as a background task and return its id; collect with task_output or stop with task_kill. */
|
||||||
run_in_background?: boolean;
|
run_in_background?: boolean;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */
|
/** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */
|
||||||
subagent_fork(args: {
|
subagent_fork(args: {
|
||||||
/** A short (3-5 word) description of the delegated task, for display. */
|
/** A short (3-5 word) description of the delegated task, for display. */
|
||||||
@@ -116,16 +118,16 @@ declare const tools: {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
/** Run as a background task and return its id; collect with task_output or stop with task_kill. */
|
/** Run as a background task and return its id; collect with task_output or stop with task_kill. */
|
||||||
run_in_background?: boolean;
|
run_in_background?: boolean;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */
|
/** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */
|
||||||
task_kill(args: {
|
task_kill(args: {
|
||||||
/** Task id returned by the tool that started the background work. */
|
/** Task id returned by the tool that started the background work. */
|
||||||
task_id: string;
|
task_id: string;
|
||||||
/** Optional short reason, recorded in the log and forwarded to the task. */
|
/** Optional short reason, recorded in the log and forwarded to the task. */
|
||||||
reason?: string;
|
reason?: string;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** List your background tasks (running and finished) with their ids, kinds, and statuses. */
|
/** List your background tasks (running and finished) with their ids, kinds, and statuses. */
|
||||||
task_list(args: Record<string, unknown>): Promise<string>;
|
task_list(args: Record<string, JsonValue>): Promise<string>;
|
||||||
/** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */
|
/** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */
|
||||||
task_output(args: {
|
task_output(args: {
|
||||||
/** Task id returned by the tool that started the background work. */
|
/** Task id returned by the tool that started the background work. */
|
||||||
@@ -134,7 +136,7 @@ declare const tools: {
|
|||||||
wait?: boolean;
|
wait?: boolean;
|
||||||
/** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */
|
/** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */
|
||||||
timeout_ms?: number;
|
timeout_ms?: number;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */
|
/** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */
|
||||||
todo_write(args: {
|
todo_write(args: {
|
||||||
/** The COMPLETE task list, replacing any previous list. */
|
/** The COMPLETE task list, replacing any previous list. */
|
||||||
@@ -143,8 +145,8 @@ declare const tools: {
|
|||||||
content: string;
|
content: string;
|
||||||
/** pending (not started) | in_progress (now) | completed (done). */
|
/** pending (not started) | in_progress (now) | completed (done). */
|
||||||
status: "pending" | "in_progress" | "completed";
|
status: "pending" | "in_progress" | "completed";
|
||||||
})[];
|
} & Record<string, JsonValue>)[];
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */
|
/** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */
|
||||||
update_goal(args: {
|
update_goal(args: {
|
||||||
/** Exact id returned by get_goal. */
|
/** Exact id returned by get_goal. */
|
||||||
@@ -159,7 +161,7 @@ declare const tools: {
|
|||||||
max_goal_rounds?: number;
|
max_goal_rounds?: number;
|
||||||
/** Concrete blocking condition; required only with action blocked. */
|
/** Concrete blocking condition; required only with action blocked. */
|
||||||
blocked_reason?: string;
|
blocked_reason?: string;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */
|
/** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */
|
||||||
workflow(args: {
|
workflow(args: {
|
||||||
/** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`). */
|
/** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`). */
|
||||||
@@ -173,7 +175,7 @@ declare const tools: {
|
|||||||
/** Optional guidance on when this workflow applies. */
|
/** Optional guidance on when this workflow applies. */
|
||||||
whenToUse?: string;
|
whenToUse?: string;
|
||||||
/** Optional phase declarations matched by phase() calls. */
|
/** Optional phase declarations matched by phase() calls. */
|
||||||
phases?: {
|
phases?: ({
|
||||||
/** The phase title phase() calls match by exact string. */
|
/** The phase title phase() calls match by exact string. */
|
||||||
title: string;
|
title: string;
|
||||||
/** Optional one-line description of the phase. */
|
/** Optional one-line description of the phase. */
|
||||||
@@ -182,11 +184,11 @@ declare const tools: {
|
|||||||
provider?: string;
|
provider?: string;
|
||||||
/** Optional model override this phase is expected to use. */
|
/** Optional model override this phase is expected to use. */
|
||||||
model?: string;
|
model?: string;
|
||||||
}[];
|
} & Record<string, JsonValue>)[];
|
||||||
};
|
} & Record<string, JsonValue>;
|
||||||
/** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */
|
/** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */
|
||||||
args?: Record<string, unknown>;
|
args?: Record<string, JsonValue>;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Create or fully replace a UTF-8 text file. */
|
/** Create or fully replace a UTF-8 text file. */
|
||||||
write(args: {
|
write(args: {
|
||||||
/** Path to write, resolved by the filesystem backend. */
|
/** Path to write, resolved by the filesystem backend. */
|
||||||
@@ -197,6 +199,6 @@ declare const tools: {
|
|||||||
sandbox_permissions?: "workspace-write" | "danger-full-access";
|
sandbox_permissions?: "workspace-write" | "danger-full-access";
|
||||||
/** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */
|
/** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */
|
||||||
justification?: string;
|
justification?: string;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ Pass `run_code` the body of an async TypeScript function (erasable syntax only
|
|||||||
The available tools:
|
The available tools:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
|
||||||
|
|
||||||
declare const tools: {
|
declare const tools: {
|
||||||
/** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */
|
/** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */
|
||||||
bash(args: {
|
bash(args: {
|
||||||
@@ -53,14 +55,14 @@ declare const tools: {
|
|||||||
sandbox_permissions?: "workspace-write" | "danger-full-access";
|
sandbox_permissions?: "workspace-write" | "danger-full-access";
|
||||||
/** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */
|
/** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */
|
||||||
justification?: string;
|
justification?: string;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. */
|
/** Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. */
|
||||||
create_goal(args: {
|
create_goal(args: {
|
||||||
/** The concrete completion objective inferred from the direct human request. */
|
/** The concrete completion objective inferred from the direct human request. */
|
||||||
objective: string;
|
objective: string;
|
||||||
/** Optional positive safe-integer limit on automatic continuation rounds. */
|
/** Optional positive safe-integer limit on automatic continuation rounds. */
|
||||||
max_goal_rounds?: number;
|
max_goal_rounds?: number;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Edit an existing UTF-8 text file by replacing literal text. */
|
/** Edit an existing UTF-8 text file by replacing literal text. */
|
||||||
edit(args: {
|
edit(args: {
|
||||||
/** Path to edit, resolved by the filesystem backend. */
|
/** Path to edit, resolved by the filesystem backend. */
|
||||||
@@ -75,16 +77,16 @@ declare const tools: {
|
|||||||
sandbox_permissions?: "workspace-write" | "danger-full-access";
|
sandbox_permissions?: "workspace-write" | "danger-full-access";
|
||||||
/** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */
|
/** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */
|
||||||
justification?: string;
|
justification?: string;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */
|
/** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */
|
||||||
get_goal(args: Record<string, unknown>): Promise<string>;
|
get_goal(args: Record<string, JsonValue>): Promise<string>;
|
||||||
/** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */
|
/** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */
|
||||||
ralph(args: {
|
ralph(args: {
|
||||||
/** The immutable completion objective for every fresh Ralph round. */
|
/** The immutable completion objective for every fresh Ralph round. */
|
||||||
objective: string;
|
objective: string;
|
||||||
/** Optional positive safe-integer round cap, bounded by the deployment ceiling. */
|
/** Optional positive safe-integer round cap, bounded by the deployment ceiling. */
|
||||||
maxRounds?: number;
|
maxRounds?: number;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Read a UTF-8 text file and return line-numbered content. */
|
/** Read a UTF-8 text file and return line-numbered content. */
|
||||||
read(args: {
|
read(args: {
|
||||||
/** Path to read, resolved by the filesystem backend. */
|
/** Path to read, resolved by the filesystem backend. */
|
||||||
@@ -93,12 +95,12 @@ declare const tools: {
|
|||||||
offset?: number;
|
offset?: number;
|
||||||
/** Maximum number of lines to return. Defaults to 2000. */
|
/** Maximum number of lines to return. Defaults to 2000. */
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */
|
/** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */
|
||||||
skill(args: {
|
skill(args: {
|
||||||
/** The exact skill name from the available skills list. */
|
/** The exact skill name from the available skills list. */
|
||||||
name: string;
|
name: string;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */
|
/** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */
|
||||||
subagent(args: {
|
subagent(args: {
|
||||||
/** A short (3-5 word) description of the delegated task, for display. */
|
/** A short (3-5 word) description of the delegated task, for display. */
|
||||||
@@ -107,7 +109,7 @@ declare const tools: {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
/** Run as a background task and return its id; collect with task_output or stop with task_kill. */
|
/** Run as a background task and return its id; collect with task_output or stop with task_kill. */
|
||||||
run_in_background?: boolean;
|
run_in_background?: boolean;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */
|
/** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */
|
||||||
subagent_fork(args: {
|
subagent_fork(args: {
|
||||||
/** A short (3-5 word) description of the delegated task, for display. */
|
/** A short (3-5 word) description of the delegated task, for display. */
|
||||||
@@ -116,16 +118,16 @@ declare const tools: {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
/** Run as a background task and return its id; collect with task_output or stop with task_kill. */
|
/** Run as a background task and return its id; collect with task_output or stop with task_kill. */
|
||||||
run_in_background?: boolean;
|
run_in_background?: boolean;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */
|
/** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */
|
||||||
task_kill(args: {
|
task_kill(args: {
|
||||||
/** Task id returned by the tool that started the background work. */
|
/** Task id returned by the tool that started the background work. */
|
||||||
task_id: string;
|
task_id: string;
|
||||||
/** Optional short reason, recorded in the log and forwarded to the task. */
|
/** Optional short reason, recorded in the log and forwarded to the task. */
|
||||||
reason?: string;
|
reason?: string;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** List your background tasks (running and finished) with their ids, kinds, and statuses. */
|
/** List your background tasks (running and finished) with their ids, kinds, and statuses. */
|
||||||
task_list(args: Record<string, unknown>): Promise<string>;
|
task_list(args: Record<string, JsonValue>): Promise<string>;
|
||||||
/** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */
|
/** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */
|
||||||
task_output(args: {
|
task_output(args: {
|
||||||
/** Task id returned by the tool that started the background work. */
|
/** Task id returned by the tool that started the background work. */
|
||||||
@@ -134,7 +136,7 @@ declare const tools: {
|
|||||||
wait?: boolean;
|
wait?: boolean;
|
||||||
/** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */
|
/** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */
|
||||||
timeout_ms?: number;
|
timeout_ms?: number;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */
|
/** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */
|
||||||
todo_write(args: {
|
todo_write(args: {
|
||||||
/** The COMPLETE task list, replacing any previous list. */
|
/** The COMPLETE task list, replacing any previous list. */
|
||||||
@@ -143,8 +145,8 @@ declare const tools: {
|
|||||||
content: string;
|
content: string;
|
||||||
/** pending (not started) | in_progress (now) | completed (done). */
|
/** pending (not started) | in_progress (now) | completed (done). */
|
||||||
status: "pending" | "in_progress" | "completed";
|
status: "pending" | "in_progress" | "completed";
|
||||||
})[];
|
} & Record<string, JsonValue>)[];
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */
|
/** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */
|
||||||
update_goal(args: {
|
update_goal(args: {
|
||||||
/** Exact id returned by get_goal. */
|
/** Exact id returned by get_goal. */
|
||||||
@@ -159,7 +161,7 @@ declare const tools: {
|
|||||||
max_goal_rounds?: number;
|
max_goal_rounds?: number;
|
||||||
/** Concrete blocking condition; required only with action blocked. */
|
/** Concrete blocking condition; required only with action blocked. */
|
||||||
blocked_reason?: string;
|
blocked_reason?: string;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */
|
/** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */
|
||||||
workflow(args: {
|
workflow(args: {
|
||||||
/** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`). */
|
/** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`). */
|
||||||
@@ -173,7 +175,7 @@ declare const tools: {
|
|||||||
/** Optional guidance on when this workflow applies. */
|
/** Optional guidance on when this workflow applies. */
|
||||||
whenToUse?: string;
|
whenToUse?: string;
|
||||||
/** Optional phase declarations matched by phase() calls. */
|
/** Optional phase declarations matched by phase() calls. */
|
||||||
phases?: {
|
phases?: ({
|
||||||
/** The phase title phase() calls match by exact string. */
|
/** The phase title phase() calls match by exact string. */
|
||||||
title: string;
|
title: string;
|
||||||
/** Optional one-line description of the phase. */
|
/** Optional one-line description of the phase. */
|
||||||
@@ -182,11 +184,11 @@ declare const tools: {
|
|||||||
provider?: string;
|
provider?: string;
|
||||||
/** Optional model override this phase is expected to use. */
|
/** Optional model override this phase is expected to use. */
|
||||||
model?: string;
|
model?: string;
|
||||||
}[];
|
} & Record<string, JsonValue>)[];
|
||||||
};
|
} & Record<string, JsonValue>;
|
||||||
/** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */
|
/** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */
|
||||||
args?: Record<string, unknown>;
|
args?: Record<string, JsonValue>;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
/** Create or fully replace a UTF-8 text file. */
|
/** Create or fully replace a UTF-8 text file. */
|
||||||
write(args: {
|
write(args: {
|
||||||
/** Path to write, resolved by the filesystem backend. */
|
/** Path to write, resolved by the filesystem backend. */
|
||||||
@@ -197,6 +199,6 @@ declare const tools: {
|
|||||||
sandbox_permissions?: "workspace-write" | "danger-full-access";
|
sandbox_permissions?: "workspace-write" | "danger-full-access";
|
||||||
/** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */
|
/** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */
|
||||||
justification?: string;
|
justification?: string;
|
||||||
}): Promise<string>;
|
} & Record<string, JsonValue>): Promise<string>;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -288,6 +288,7 @@
|
|||||||
"description": "The COMPLETE task list, replacing any previous list.",
|
"description": "The COMPLETE task list, replacing any previous list.",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"content": {
|
"content": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -373,6 +374,7 @@
|
|||||||
"meta": {
|
"meta": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "The workflow identity block (plain JSON — never code).",
|
"description": "The workflow identity block (plain JSON — never code).",
|
||||||
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -391,6 +393,7 @@
|
|||||||
"description": "Optional phase declarations matched by phase() calls.",
|
"description": "Optional phase declarations matched by phase() calls.",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"title": {
|
"title": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -422,7 +425,8 @@
|
|||||||
},
|
},
|
||||||
"args": {
|
"args": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."
|
"description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).",
|
||||||
|
"additionalProperties": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -755,6 +759,7 @@
|
|||||||
"description": "The COMPLETE task list, replacing any previous list.",
|
"description": "The COMPLETE task list, replacing any previous list.",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"content": {
|
"content": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -840,6 +845,7 @@
|
|||||||
"meta": {
|
"meta": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "The workflow identity block (plain JSON — never code).",
|
"description": "The workflow identity block (plain JSON — never code).",
|
||||||
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -858,6 +864,7 @@
|
|||||||
"description": "Optional phase declarations matched by phase() calls.",
|
"description": "Optional phase declarations matched by phase() calls.",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"title": {
|
"title": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -889,7 +896,8 @@
|
|||||||
},
|
},
|
||||||
"args": {
|
"args": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."
|
"description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).",
|
||||||
|
"additionalProperties": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -288,6 +288,7 @@
|
|||||||
"description": "The COMPLETE task list, replacing any previous list.",
|
"description": "The COMPLETE task list, replacing any previous list.",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"content": {
|
"content": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -373,6 +374,7 @@
|
|||||||
"meta": {
|
"meta": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "The workflow identity block (plain JSON — never code).",
|
"description": "The workflow identity block (plain JSON — never code).",
|
||||||
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -391,6 +393,7 @@
|
|||||||
"description": "Optional phase declarations matched by phase() calls.",
|
"description": "Optional phase declarations matched by phase() calls.",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"title": {
|
"title": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -422,7 +425,8 @@
|
|||||||
},
|
},
|
||||||
"args": {
|
"args": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."
|
"description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).",
|
||||||
|
"additionalProperties": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -755,6 +759,7 @@
|
|||||||
"description": "The COMPLETE task list, replacing any previous list.",
|
"description": "The COMPLETE task list, replacing any previous list.",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"content": {
|
"content": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -840,6 +845,7 @@
|
|||||||
"meta": {
|
"meta": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "The workflow identity block (plain JSON — never code).",
|
"description": "The workflow identity block (plain JSON — never code).",
|
||||||
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -858,6 +864,7 @@
|
|||||||
"description": "Optional phase declarations matched by phase() calls.",
|
"description": "Optional phase declarations matched by phase() calls.",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"title": {
|
"title": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -889,7 +896,8 @@
|
|||||||
},
|
},
|
||||||
"args": {
|
"args": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."
|
"description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).",
|
||||||
|
"additionalProperties": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -288,6 +288,7 @@
|
|||||||
"description": "The COMPLETE task list, replacing any previous list.",
|
"description": "The COMPLETE task list, replacing any previous list.",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"content": {
|
"content": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -373,6 +374,7 @@
|
|||||||
"meta": {
|
"meta": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "The workflow identity block (plain JSON — never code).",
|
"description": "The workflow identity block (plain JSON — never code).",
|
||||||
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -391,6 +393,7 @@
|
|||||||
"description": "Optional phase declarations matched by phase() calls.",
|
"description": "Optional phase declarations matched by phase() calls.",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"title": {
|
"title": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -422,7 +425,8 @@
|
|||||||
},
|
},
|
||||||
"args": {
|
"args": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."
|
"description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).",
|
||||||
|
"additionalProperties": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -288,6 +288,7 @@
|
|||||||
"description": "The COMPLETE task list, replacing any previous list.",
|
"description": "The COMPLETE task list, replacing any previous list.",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"content": {
|
"content": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -373,6 +374,7 @@
|
|||||||
"meta": {
|
"meta": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "The workflow identity block (plain JSON — never code).",
|
"description": "The workflow identity block (plain JSON — never code).",
|
||||||
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -391,6 +393,7 @@
|
|||||||
"description": "Optional phase declarations matched by phase() calls.",
|
"description": "Optional phase declarations matched by phase() calls.",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"title": {
|
"title": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -422,7 +425,8 @@
|
|||||||
},
|
},
|
||||||
"args": {
|
"args": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."
|
"description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).",
|
||||||
|
"additionalProperties": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -288,6 +288,7 @@
|
|||||||
"description": "The COMPLETE task list, replacing any previous list.",
|
"description": "The COMPLETE task list, replacing any previous list.",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"content": {
|
"content": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -373,6 +374,7 @@
|
|||||||
"meta": {
|
"meta": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "The workflow identity block (plain JSON — never code).",
|
"description": "The workflow identity block (plain JSON — never code).",
|
||||||
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -391,6 +393,7 @@
|
|||||||
"description": "Optional phase declarations matched by phase() calls.",
|
"description": "Optional phase declarations matched by phase() calls.",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"title": {
|
"title": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -422,7 +425,8 @@
|
|||||||
},
|
},
|
||||||
"args": {
|
"args": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."
|
"description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).",
|
||||||
|
"additionalProperties": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -206,7 +206,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 {
|
interface BashToolArgs {
|
||||||
command: string
|
command: string
|
||||||
description: string
|
description: string
|
||||||
|
|||||||
@@ -287,7 +287,7 @@ describe('bash tool', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Type and required-key violations are rejected by the harness
|
// 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([
|
it.each([
|
||||||
[{}, /missing required property "command"/],
|
[{}, /missing required property "command"/],
|
||||||
[{ command: 42, description: 'd' }, /"command" must be a string/],
|
[{ command: 42, description: 'd' }, /"command" must be a string/],
|
||||||
@@ -303,7 +303,7 @@ describe('bash tool', () => {
|
|||||||
expect(text(result)).toMatch(pattern)
|
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([
|
it.each([
|
||||||
[{ command: ' ', description: 'd' }, /invalid command/],
|
[{ command: ' ', description: 'd' }, /invalid command/],
|
||||||
[{ command: 'x', description: ' ' }, /invalid description/],
|
[{ command: 'x', description: ' ' }, /invalid description/],
|
||||||
|
|||||||
@@ -1336,6 +1336,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
|||||||
name: 'InjectOptions',
|
name: 'InjectOptions',
|
||||||
declaration: 'export interface InjectOptions extends SendOptions {\n meta?: JsonValue;\n}',
|
declaration: 'export interface InjectOptions extends SendOptions {\n meta?: JsonValue;\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',
|
name: 'JsonValue',
|
||||||
declaration: 'export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n};',
|
declaration: 'export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n};',
|
||||||
@@ -1368,6 +1380,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
|||||||
name: 'MessageSourceMap',
|
name: 'MessageSourceMap',
|
||||||
declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n plugin: {\n kind: \'plugin\';\n plugin: string;\n };\n}',
|
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: 'PresetOption',
|
name: 'PresetOption',
|
||||||
declaration: 'export interface PresetOption {\n value: string;\n name: string;\n description?: string;\n}',
|
declaration: 'export interface PresetOption {\n value: string;\n name: string;\n description?: string;\n}',
|
||||||
@@ -1544,22 +1560,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
|||||||
name: 'StreamChunk',
|
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};',
|
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',
|
name: 'SubagentCapabilities',
|
||||||
declaration: 'export interface SubagentCapabilities {\n readonly outputSchema: boolean;\n readonly depthLimit: boolean;\n readonly toolFilter: boolean;\n readonly persona: boolean;\n}',
|
declaration: 'export interface SubagentCapabilities {\n readonly outputSchema: boolean;\n readonly depthLimit: boolean;\n readonly toolFilter: boolean;\n readonly persona: boolean;\n}',
|
||||||
@@ -1578,7 +1578,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'SubagentStartRequest',
|
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',
|
name: 'SubagentStopReason',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* 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` /
|
* normalization + validation with teaching errors, the marker-guarded `harness.defineTool` /
|
||||||
* `harness.registerTool` pair, the SANDBOX CONTEXT FAÇADE a mounted plugin's `apply` receives
|
* `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
|
* in place of the real `ctx`, and the plugin-shape helpers the mount lifecycle narrows sandbox
|
||||||
@@ -15,12 +15,13 @@
|
|||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import type { Plugin } from 'cordis'
|
import type { Plugin } from 'cordis'
|
||||||
import { scopeOf } from '@deepseek-ai/dsh-scope'
|
import { scopeOf } from '@deepseek-ai/dsh-scope'
|
||||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
import { assertSupportedJsonSchema, defineTool } from '@deepseek-ai/dsh-tools'
|
||||||
import type { ToolDefinition, ToolExecuteReturn } from '@deepseek-ai/dsh-tools'
|
import type { ToolDefinition, ToolExecuteReturn } from '@deepseek-ai/dsh-tools'
|
||||||
|
|
||||||
const DYNAMIC_TOOL = Symbol('tool-cordis.dynamic-tool')
|
const DYNAMIC_TOOL = Symbol('tool-cordis.dynamic-tool')
|
||||||
const SCHEMA_TYPES = new Set<unknown>(['string', 'number', 'boolean', 'object', 'array'])
|
const SCHEMA_TYPES = new Set<unknown>(['string', 'number', 'integer', 'boolean', 'null', 'object', 'array', 'json'])
|
||||||
const VALID_TYPES = '\'string\' | \'number\' | \'boolean\' | \'object\' | \'array\''
|
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 DynamicToolDefinition = ToolDefinition & { [DYNAMIC_TOOL]: true }
|
||||||
type DynamicToolMarker = { [DYNAMIC_TOOL]?: unknown }
|
type DynamicToolMarker = { [DYNAMIC_TOOL]?: unknown }
|
||||||
@@ -29,70 +30,196 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return Object.prototype.toString.call(value) === '[object Object]'
|
return Object.prototype.toString.call(value) === '[object Object]'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
if (Number.isFinite(value) && !Object.is(value, -0)) return value
|
||||||
|
throw new Error(`harness.defineTool ${path} must be lossless JSON data`)
|
||||||
|
}
|
||||||
|
if (typeof value !== 'object') throw new Error(`harness.defineTool ${path} must be lossless JSON data`)
|
||||||
|
if (seen.has(value)) throw new Error(`harness.defineTool ${path} must be lossless JSON data`)
|
||||||
|
seen.add(value)
|
||||||
|
try {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const output: unknown[] = []
|
||||||
|
for (let index = 0; index < value.length; index++) {
|
||||||
|
if (!Object.hasOwn(value, index)) throw new Error(`harness.defineTool ${path} must be lossless JSON data`)
|
||||||
|
output.push(cloneJson(value[index], `${path}[${index}]`, seen))
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
if (!isPlainRecord(value)) throw new Error(`harness.defineTool ${path} must be lossless JSON data`)
|
||||||
|
const output: Record<string, unknown> = {}
|
||||||
|
for (const [key, entry] of Object.entries(value)) output[key] = cloneJson(entry, `${path}.${key}`, seen)
|
||||||
|
return output
|
||||||
|
} finally {
|
||||||
|
seen.delete(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
||||||
|
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
|
* Normalize a sandbox-provided `parameters` value into a fresh host-realm
|
||||||
* SchemaSpec. Accepts the DSL directly, or the JSON-Schema-style
|
* ParameterSchemaSpec. A raw JSON-Schema object wrapper retains its open root
|
||||||
* `{ type: 'object', properties, required: […] }` wrapper models write by
|
* default, while the direct DSL is already an implicit open property map.
|
||||||
* 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> {
|
function normalizeParameterSchemaSpec(value: unknown, path = 'parameters'): {
|
||||||
|
spec: Record<string, unknown>
|
||||||
|
rootAnnotations?: Record<string, unknown>
|
||||||
|
} {
|
||||||
if (!isPlainRecord(value)) {
|
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
|
if (value.type === 'object') {
|
||||||
const requiredNames = new Set<unknown>()
|
assertSchemaKeys(value, path, ['type', 'properties', 'required', 'additionalProperties', ...ANNOTATION_KEYS])
|
||||||
if (value.type === 'object' && isPlainRecord(value.properties)) {
|
if (!isPlainRecord(value.properties)) {
|
||||||
if (Array.isArray(value.required)) {
|
throw new Error(`harness.defineTool ${path}.properties must be an object of schemas`)
|
||||||
for (const name of value.required) requiredNames.add(name)
|
}
|
||||||
|
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
|
|
||||||
}
|
}
|
||||||
|
return { spec: normalizePropertyMap(value, path, new Set(), false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (!Array.isArray(value) || value.some(name => typeof name !== 'string')) {
|
||||||
|
throw new Error(`harness.defineTool ${path} must be an array of declared property names`)
|
||||||
|
}
|
||||||
|
const names = new Set(value as string[])
|
||||||
|
for (const name of names) {
|
||||||
|
if (!Object.hasOwn(properties, name)) throw new Error(`harness.defineTool ${path} names undeclared property ${JSON.stringify(name)}`)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize one implicit property map. */
|
||||||
|
function normalizePropertyMap(
|
||||||
|
entries: Record<string, unknown>,
|
||||||
|
path: string,
|
||||||
|
requiredNames: ReadonlySet<string>,
|
||||||
|
raw: boolean,
|
||||||
|
): Record<string, unknown> {
|
||||||
const spec: Record<string, unknown> = {}
|
const spec: Record<string, unknown> = {}
|
||||||
for (const [key, prop] of Object.entries(entries)) {
|
for (const [key, prop] of Object.entries(entries)) {
|
||||||
spec[key] = normalizeSchemaProp(prop, `${path}.${key}`, requiredNames.has(key))
|
spec[key] = normalizeValueSchema(prop, `${path}.${key}`, requiredNames.has(key), raw, true)
|
||||||
}
|
}
|
||||||
return spec
|
return spec
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Normalize one property: `integer` → `number`, `required: false` → absent, nested wrappers unwrapped recursively. */
|
/** Normalize one property or nested value schema into the host realm. */
|
||||||
function normalizeSchemaProp(value: unknown, path: string, forceRequired = false): Record<string, unknown> {
|
function normalizeValueSchema(
|
||||||
|
value: unknown,
|
||||||
|
path: string,
|
||||||
|
forceRequired = false,
|
||||||
|
raw = false,
|
||||||
|
parameterProperty = false,
|
||||||
|
): Record<string, unknown> {
|
||||||
if (!isPlainRecord(value)) {
|
if (!isPlainRecord(value)) {
|
||||||
throw new Error(`harness.defineTool ${path} must be a SchemaSpec property object`)
|
throw new Error(`harness.defineTool ${path} must be a ParameterSchemaSpec property object`)
|
||||||
}
|
}
|
||||||
const type = value.type === 'integer' ? 'number' : value.type
|
const requiredKey = parameterProperty && !raw ? ['required'] : []
|
||||||
if (!SCHEMA_TYPES.has(type)) {
|
if (parameterProperty && raw && Object.hasOwn(value, 'required') && value.type !== 'object') {
|
||||||
|
throw new Error(`harness.defineTool ${path}.required belongs to the containing raw object schema`)
|
||||||
|
}
|
||||||
|
if (parameterProperty && !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> = {}
|
||||||
|
if (forceRequired || value.required === true) prop.required = true
|
||||||
|
copyAnnotations(value, prop, path)
|
||||||
|
|
||||||
|
if (Object.hasOwn(value, 'oneOf')) {
|
||||||
|
assertSchemaKeys(value, path, ['oneOf', ...requiredKey, ...ANNOTATION_KEYS])
|
||||||
|
if (!Array.isArray(value.oneOf)) throw new Error(`harness.defineTool ${path}.oneOf must contain at least two schemas`)
|
||||||
|
prop.oneOf = value.oneOf.map((branch, index) => normalizeValueSchema(branch, `${path}.oneOf[${index}]`, false, raw))
|
||||||
|
return prop
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw && !Object.hasOwn(value, 'type')) {
|
||||||
|
assertSchemaKeys(value, path, ANNOTATION_KEYS)
|
||||||
|
prop.type = 'json'
|
||||||
|
return prop
|
||||||
|
}
|
||||||
|
if (!SCHEMA_TYPES.has(value.type) || raw && value.type === 'json') {
|
||||||
throw new Error(`harness.defineTool ${path} must declare a valid type: ${VALID_TYPES} (got ${JSON.stringify(value.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
|
const type = value.type
|
||||||
// children (handled by the nested unwrap below); everywhere else `required`
|
prop.type = type
|
||||||
// must be a boolean, and `false` means optional.
|
|
||||||
const nestedRequiredArray = type === 'object' && Array.isArray(value.required)
|
switch (type) {
|
||||||
if (value.required !== undefined && typeof value.required !== 'boolean' && !nestedRequiredArray) {
|
case 'object': {
|
||||||
throw new Error(`harness.defineTool ${path}.required must be a boolean when present`)
|
assertSchemaKeys(value, path, ['type', 'properties', 'additionalProperties', ...requiredKey, ...(raw ? ['required'] : []), ...ANNOTATION_KEYS])
|
||||||
}
|
if (!raw && (!Object.hasOwn(value, 'additionalProperties') || typeof value.additionalProperties !== 'boolean')) {
|
||||||
const prop: Record<string, unknown> = { type }
|
throw new Error(`harness.defineTool ${path}.additionalProperties must be explicitly true or false`)
|
||||||
if (forceRequired || value.required === true) prop.required = true
|
}
|
||||||
if (typeof value.description === 'string') prop.description = value.description
|
if (raw && Object.hasOwn(value, 'additionalProperties') && typeof value.additionalProperties !== 'boolean') {
|
||||||
if (Array.isArray(value.enum)) prop.enum = [...value.enum as unknown[]]
|
throw new Error(`harness.defineTool ${path}.additionalProperties must be a boolean`)
|
||||||
if (value.default !== undefined) prop.default = value.default
|
}
|
||||||
if (value.properties !== undefined) {
|
if (raw && Object.hasOwn(value, 'required') && value.required === undefined) {
|
||||||
if (type !== 'object') {
|
throw new Error(`harness.defineTool ${path}.required must be an array of declared property names`)
|
||||||
throw new Error(`harness.defineTool ${path}.properties is only valid for type "object"`)
|
}
|
||||||
|
prop.additionalProperties = raw ? value.additionalProperties ?? true : value.additionalProperties
|
||||||
|
if (Object.hasOwn(value, 'properties')) {
|
||||||
|
if (!isPlainRecord(value.properties)) throw new Error(`harness.defineTool ${path}.properties must be an object of schemas`)
|
||||||
|
const nestedRequired = raw ? normalizeRequiredNames(value.required, value.properties, `${path}.required`) : new Set<string>()
|
||||||
|
prop.properties = normalizePropertyMap(value.properties, `${path}.properties`, nestedRequired, raw)
|
||||||
|
} else if (raw && value.required !== undefined) {
|
||||||
|
normalizeRequiredNames(value.required, {}, `${path}.required`)
|
||||||
|
}
|
||||||
|
return prop
|
||||||
}
|
}
|
||||||
// Re-wrap so the nested unwrap applies a nested `required` array too.
|
case 'array':
|
||||||
prop.properties = normalizeSchemaSpec(
|
assertSchemaKeys(value, path, ['type', 'items', ...requiredKey, ...ANNOTATION_KEYS])
|
||||||
{ type: 'object', properties: value.properties, required: value.required },
|
if (Object.hasOwn(value, 'items')) prop.items = normalizeValueSchema(value.items, `${path}.items`, false, raw)
|
||||||
`${path}.properties`,
|
return prop
|
||||||
)
|
case 'string':
|
||||||
|
case 'number':
|
||||||
|
case 'integer':
|
||||||
|
case 'boolean':
|
||||||
|
case 'null':
|
||||||
|
assertSchemaKeys(value, path, ['type', 'enum', 'const', ...requiredKey, ...ANNOTATION_KEYS])
|
||||||
|
if (Object.hasOwn(value, 'enum')) {
|
||||||
|
prop.enum = Array.isArray(value.enum)
|
||||||
|
? value.enum.map((entry, index) => cloneJson(entry, `${path}.enum[${index}]`))
|
||||||
|
: value.enum
|
||||||
|
}
|
||||||
|
if (Object.hasOwn(value, 'const')) prop.const = cloneJson(value.const, `${path}.const`)
|
||||||
|
return prop
|
||||||
|
case 'json':
|
||||||
|
assertSchemaKeys(value, path, ['type', ...requiredKey, ...ANNOTATION_KEYS])
|
||||||
|
return prop
|
||||||
|
/* 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}`)
|
||||||
}
|
}
|
||||||
if (value.items !== undefined) {
|
|
||||||
if (type !== 'array') {
|
|
||||||
throw new Error(`harness.defineTool ${path}.items is only valid for type "array"`)
|
|
||||||
}
|
|
||||||
prop.items = normalizeSchemaProp(value.items, `${path}.items`)
|
|
||||||
}
|
|
||||||
return prop
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function markDynamicTool(tool: ToolDefinition): DynamicToolDefinition {
|
function markDynamicTool(tool: ToolDefinition): DynamicToolDefinition {
|
||||||
@@ -160,19 +287,22 @@ function assertExecuteReturn(value: unknown): ToolExecuteReturn {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The `harness.defineTool` handed into the sandbox: the real DSL, with `parameters` normalized
|
* 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,
|
* into a fresh host-realm ParameterSchemaSpec (raw object wrappers unwrapped,
|
||||||
* `required: false` dropped) and the tool's `execute` return normalized into the host realm
|
* 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
|
* via a JSON round-trip. Non-JSON or wrong-shape output fails that call instead of poisoning
|
||||||
* the session log.
|
* 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.
|
* @returns the marker-tagged definition `harness.registerTool` (and the guarded `ctx.tools.register`) accepts.
|
||||||
*/
|
*/
|
||||||
export function sandboxDefineTool(options: Parameters<typeof defineTool>[0]): ToolDefinition {
|
export function sandboxDefineTool(options: Parameters<typeof defineTool>[0]): ToolDefinition {
|
||||||
const parameters = normalizeSchemaSpec((options as { parameters?: unknown }).parameters)
|
const normalized = normalizeParameterSchemaSpec((options as { parameters?: unknown }).parameters)
|
||||||
const tool = defineTool({ ...options, parameters } as Parameters<typeof defineTool>[0])
|
const tool = defineTool({ ...options, parameters: normalized.spec } as Parameters<typeof defineTool>[0])
|
||||||
|
const parameters = { ...tool.parameters, ...normalized.rootAnnotations }
|
||||||
|
assertSupportedJsonSchema(parameters)
|
||||||
const execute = tool.execute.bind(tool)
|
const execute = tool.execute.bind(tool)
|
||||||
return markDynamicTool({
|
return markDynamicTool({
|
||||||
...tool,
|
...tool,
|
||||||
|
parameters,
|
||||||
async execute(args, exec) {
|
async execute(args, exec) {
|
||||||
// JSON.stringify yields NO JSON for an undefined (or function/symbol)
|
// JSON.stringify yields NO JSON for an undefined (or function/symbol)
|
||||||
// return despite its string-typed signature — route that into
|
// return despite its string-typed signature — route that into
|
||||||
|
|||||||
@@ -121,9 +121,9 @@ export function apply(ctx: Context, config: Config): void {
|
|||||||
+ '`harness.registerTool(ctx, harness.defineTool({ name, description, parameters: '
|
+ '`harness.registerTool(ctx, harness.defineTool({ name, description, parameters: '
|
||||||
+ '{ text: { type: \'string\', required: true } }, async execute(args) { … } }))` '
|
+ '{ text: { type: \'string\', required: true } }, async execute(args) { … } }))` '
|
||||||
+ 'to give yourself a new tool — it becomes callable on your NEXT step. '
|
+ '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\', '
|
+ 'Tool parameters: each key IS a property — { type: \'string\'|\'number\'|\'integer\'|\'boolean\'|\'null\'|\'object\'|\'array\'|\'json\', '
|
||||||
+ 'required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style '
|
+ 'required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and '
|
||||||
+ '{ type: \'object\', properties, required: […] } wrapper and type \'integer\' are also accepted and normalized. A '
|
+ '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 an ARRAY of content blocks, e.g. `return '
|
+ 'tool\'s `execute` MUST return an ARRAY of content blocks, e.g. `return '
|
||||||
+ '[{ type: \'text\', text: someString }]` — never a bare string. '
|
+ '[{ type: \'text\', text: someString }]` — never a bare string. '
|
||||||
+ 'Mounts can COMPOSE: one plugin may `ctx.provide(\'name\', value)` a service and '
|
+ 'Mounts can COMPOSE: one plugin may `ctx.provide(\'name\', value)` a service and '
|
||||||
|
|||||||
@@ -156,11 +156,14 @@ describe('cordis_mount', () => {
|
|||||||
description: 'written in the JSON-Schema dialect',
|
description: 'written in the JSON-Schema dialect',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
title: 'Raw parameters',
|
||||||
|
default: { text: 'default' },
|
||||||
|
examples: [{ text: 'example' }],
|
||||||
properties: {
|
properties: {
|
||||||
text: { type: 'string', description: 'the text' },
|
text: { type: 'string', description: 'the text' },
|
||||||
count: { type: 'integer', default: 1 },
|
count: { type: 'integer', default: 1 },
|
||||||
mode: { type: 'string', enum: ['fast', 'slow'] },
|
mode: { type: 'string', enum: ['fast', 'slow'] },
|
||||||
extra: { type: 'string', required: false },
|
extra: { type: 'string' },
|
||||||
},
|
},
|
||||||
required: ['text'],
|
required: ['text'],
|
||||||
},
|
},
|
||||||
@@ -173,14 +176,19 @@ describe('cordis_mount', () => {
|
|||||||
expect(result.isError).toBe(false)
|
expect(result.isError).toBe(false)
|
||||||
|
|
||||||
// The registered schema is canonical JSON Schema derived from the DSL:
|
// 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 schema = ctx.tools.schemas().find(s => s.name === 'json_schema_tool')!
|
||||||
const parameters = schema.parameters as {
|
const parameters = schema.parameters as {
|
||||||
properties: Record<string, { type: string; enum?: string[]; default?: unknown }>
|
properties: Record<string, { type: string; enum?: string[]; default?: unknown }>
|
||||||
required?: string[]
|
required?: string[]
|
||||||
}
|
}
|
||||||
expect(parameters.required).toEqual(['text'])
|
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.count!.default).toBe(1)
|
||||||
expect(parameters.properties.mode!.enum).toEqual(['fast', 'slow'])
|
expect(parameters.properties.mode!.enum).toEqual(['fast', 'slow'])
|
||||||
// Arg validation enforces the normalized spec: text required, extra not.
|
// Arg validation enforces the normalized spec: text required, extra not.
|
||||||
@@ -202,7 +210,10 @@ describe('cordis_mount', () => {
|
|||||||
name: 'nested_json_schema_tool',
|
name: 'nested_json_schema_tool',
|
||||||
description: 'nested dialect',
|
description: 'nested dialect',
|
||||||
parameters: {
|
parameters: {
|
||||||
cfg: { type: 'object', properties: { label: { type: 'string' } }, required: ['label'] },
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
cfg: { type: 'object', properties: { label: { type: 'string' } }, required: ['label'] },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async execute(args) { return [{ type: 'text', text: args.cfg.label }] },
|
async execute(args) { return [{ type: 'text', text: args.cfg.label }] },
|
||||||
}))
|
}))
|
||||||
@@ -217,14 +228,123 @@ describe('cordis_mount', () => {
|
|||||||
expect(text(await call(ctx, 'nested_json_schema_tool', { cfg: { label: 'hi' } }))).toBe('hi')
|
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 },
|
||||||
|
},
|
||||||
|
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 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' }] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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([
|
it.each([
|
||||||
['parameters: 42', 'must be a SchemaSpec object'],
|
['parameters: 42', 'must be a ParameterSchemaSpec object'],
|
||||||
['parameters: { text: 42 }', 'parameters.text must be a SchemaSpec property object'],
|
['parameters: { text: 42 }', 'parameters.text must be a ParameterSchemaSpec property object'],
|
||||||
['parameters: { text: { type: \'str\' } }', 'parameters.text must declare a valid type: \'string\' | \'number\' | \'boolean\' | \'object\' | \'array\' (got "str")'],
|
['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 a boolean when present'],
|
['parameters: { text: { type: \'string\', required: \'yes\' } }', 'parameters.text.required must be true when present'],
|
||||||
['parameters: { text: { type: \'string\', properties: {} } }', 'parameters.text.properties is only valid for type "object"'],
|
['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 only valid for type "array"'],
|
['parameters: { text: { type: \'string\', items: { type: \'string\' } } }', 'parameters.text.items is not supported by the unified schema DSL'],
|
||||||
])('rejects a malformed SchemaSpec (%s) with a teaching error', async (parameters, message) => {
|
['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: [\'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: { type: \'string\', enum: \'bad\' } }', '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: 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()
|
const ctx = await setup()
|
||||||
const result = await call(ctx, 'cordis_mount', {
|
const result = await call(ctx, 'cordis_mount', {
|
||||||
code: `
|
code: `
|
||||||
@@ -246,7 +366,7 @@ describe('cordis_mount', () => {
|
|||||||
expect(text(result)).toContain(message)
|
expect(text(result)).toContain(message)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('accepts a nested object/array SchemaSpec (the DSL recursion)', async () => {
|
it('accepts a nested object/array ParameterSchemaSpec (the DSL recursion)', async () => {
|
||||||
const ctx = await setup()
|
const ctx = await setup()
|
||||||
const result = await call(ctx, 'cordis_mount', {
|
const result = await call(ctx, 'cordis_mount', {
|
||||||
code: `
|
code: `
|
||||||
@@ -258,7 +378,7 @@ describe('cordis_mount', () => {
|
|||||||
name: 'nested_schema_tool',
|
name: 'nested_schema_tool',
|
||||||
description: 'nested',
|
description: 'nested',
|
||||||
parameters: {
|
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' } },
|
tags: { type: 'array', items: { type: 'string' } },
|
||||||
},
|
},
|
||||||
async execute(args) { return [{ type: 'text', text: args.item.label }] },
|
async execute(args) { return [{ type: 'text', text: args.item.label }] },
|
||||||
|
|||||||
@@ -80,19 +80,19 @@ ctx.tools.register(defineTool({
|
|||||||
}))
|
}))
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
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. 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.
|
||||||
|
|
||||||
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 `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.
|
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
|
### Tool-owned UI presentation
|
||||||
|
|
||||||
|
|||||||
@@ -23,27 +23,42 @@ import { renderToolsSdk } from './ts-types.ts'
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
defineTool,
|
defineTool,
|
||||||
schemaSpecToJsonSchema,
|
valueSchemaSpecToJsonSchema,
|
||||||
|
parameterSchemaSpecToJsonSchema,
|
||||||
validateArgs,
|
validateArgs,
|
||||||
ToolArgsError,
|
ToolArgsError,
|
||||||
type SchemaSpec,
|
type ValueSchemaAnnotations,
|
||||||
type SchemaProp,
|
type StringValueSchemaSpec,
|
||||||
type SchemaType,
|
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 InferArgs,
|
||||||
type DefineToolOptions,
|
type DefineToolOptions,
|
||||||
type JsonSchemaObject,
|
|
||||||
} from './schema.ts'
|
} from './schema.ts'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
assertSupportedOutputSchema,
|
assertSupportedJsonSchema,
|
||||||
validateStructuredValue,
|
assertObjectJsonSchema,
|
||||||
OutputSchemaError,
|
validateJsonSchemaValue,
|
||||||
type StructuredOutputSchema,
|
JsonSchemaError,
|
||||||
type StructuredSchemaNode,
|
type JsonSchemaNode,
|
||||||
type StructuredSchemaType,
|
type ObjectJsonSchema,
|
||||||
type StructuredScalar,
|
type JsonSchemaType,
|
||||||
|
type JsonSchemaScalar,
|
||||||
} from './json-schema.ts'
|
} from './json-schema.ts'
|
||||||
|
|
||||||
|
export type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||||
|
|
||||||
export { CodeRunFailedError, RUN_CODE_NAME } from './code-mode.ts'
|
export { CodeRunFailedError, RUN_CODE_NAME } from './code-mode.ts'
|
||||||
export { jsonSchemaToTs, renderToolsSdk } from './ts-types.ts'
|
export { jsonSchemaToTs, renderToolsSdk } from './ts-types.ts'
|
||||||
|
|
||||||
|
|||||||
@@ -1,122 +1,124 @@
|
|||||||
/**
|
/**
|
||||||
* Structured-output JSON Schema subset for subagents and workflows. It supports
|
* Enforced JSON Schema subset shared by tool outputs, generated Code Mode
|
||||||
* one scalar `type`; object `properties`/`required`/boolean
|
* types, subagents, and workflows. The subset accepts any JSON root, an
|
||||||
* `additionalProperties`; array `items`; scalar `enum`/`const`; and JSON-valued
|
* annotation-only schema for unconstrained JSON, one scalar `type`, object
|
||||||
* annotations. Unsupported or misplaced keywords reject rather than being
|
* `properties`/`required`/boolean `additionalProperties`, array `items`,
|
||||||
* accepted without enforcement, and structured-output roots must be objects.
|
* 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
|
* @module dsh-tools/json-schema
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
|
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). */
|
/** Scalar JSON values supported by `enum` and `const`. */
|
||||||
export type StructuredScalar = string | number | boolean | null
|
export type JsonSchemaScalar = string | number | boolean | null
|
||||||
|
|
||||||
/** The `type` keywords the subset accepts. */
|
/** Single-type keywords accepted by the enforced subset. */
|
||||||
export type StructuredSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null'
|
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`
|
* One raw JSON Schema node in the enforced subset. The optional fields express
|
||||||
* and `items`; see the module doc for the exact keyword semantics.
|
* the external wire shape; {@link assertSupportedJsonSchema} rejects invalid
|
||||||
|
* combinations before a caller treats the node as trusted.
|
||||||
*/
|
*/
|
||||||
export interface StructuredSchemaNode {
|
export interface JsonSchemaNode {
|
||||||
type: StructuredSchemaType
|
/** 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). */
|
/** Nested property schemas (`type: 'object'` only). */
|
||||||
properties?: Record<string, StructuredSchemaNode>
|
properties?: Record<string, JsonSchemaNode>
|
||||||
/** Required property names; each must appear in `properties`. */
|
/** Required property names; each must appear in `properties`. */
|
||||||
required?: string[]
|
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
|
additionalProperties?: boolean
|
||||||
/** Item schema (`type: 'array'` only); absent ⇒ any JSON items. */
|
/** Item schema (`type: 'array'` only); absent accepts any JSON item. */
|
||||||
items?: StructuredSchemaNode
|
items?: JsonSchemaNode
|
||||||
/** Allowed values (scalar types only). */
|
/** Allowed values for a scalar node. */
|
||||||
enum?: StructuredScalar[]
|
enum?: JsonSchemaScalar[]
|
||||||
/** The single allowed value (scalar types only). */
|
/** The single allowed value for a scalar node. */
|
||||||
const?: StructuredScalar
|
const?: JsonSchemaScalar
|
||||||
/** Annotation, ignored for validation. */
|
/** Annotation, ignored for validation. */
|
||||||
description?: string
|
description?: string
|
||||||
/** Annotation, ignored for validation. */
|
/** Annotation, ignored for validation. */
|
||||||
title?: string
|
title?: string
|
||||||
/** Annotation, ignored for validation (must still be JSON data). */
|
/** Annotation, ignored for validation but required to be lossless JSON. */
|
||||||
default?: unknown
|
default?: JsonValue
|
||||||
/** Annotation, ignored for validation (must still be JSON data). */
|
/** Annotation, ignored for validation but required to be lossless JSON. */
|
||||||
examples?: unknown
|
examples?: JsonValue
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A structured-output schema: an OBJECT-rooted {@link StructuredSchemaNode}. */
|
/** A consumer-constrained object-rooted schema. */
|
||||||
export type StructuredOutputSchema = StructuredSchemaNode & { type: 'object' }
|
export type ObjectJsonSchema = JsonSchemaNode & { type: 'object' }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thrown by {@link assertSupportedOutputSchema} when a schema falls outside the
|
* Thrown when a raw schema falls outside the enforced subset. `violations`
|
||||||
* supported subset. Extends {@link HarnessError} (`code: 'UNSUPPORTED_SCHEMA'`)
|
* lists every offending path instead of stopping at the first author error.
|
||||||
* so seam code and tool results can route on it; `violations` lists every
|
|
||||||
* offending path, not just the first.
|
|
||||||
*/
|
*/
|
||||||
export class OutputSchemaError extends HarnessError {
|
export class JsonSchemaError extends HarnessError {
|
||||||
/** The individual violation messages, in walk order. */
|
/** Individual schema violations in walk order. */
|
||||||
readonly violations: string[]
|
readonly violations: string[]
|
||||||
|
|
||||||
constructor(violations: string[]) {
|
constructor(violations: string[]) {
|
||||||
super(`unsupported output schema: ${violations.join('; ')}`, 'UNSUPPORTED_SCHEMA')
|
super(`unsupported JSON schema: ${violations.join('; ')}`, 'UNSUPPORTED_SCHEMA')
|
||||||
this.name = 'OutputSchemaError'
|
this.name = 'JsonSchemaError'
|
||||||
this.violations = violations
|
this.violations = violations
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The keywords the subset accepts, checked (`constraint`) or ignored (`annotation`). */
|
const CONSTRAINT_KEYWORDS = new Set([
|
||||||
const CONSTRAINT_KEYWORDS = new Set(['type', 'properties', 'required', 'additionalProperties', 'items', 'enum', 'const'])
|
'type',
|
||||||
|
'oneOf',
|
||||||
|
'properties',
|
||||||
|
'required',
|
||||||
|
'additionalProperties',
|
||||||
|
'items',
|
||||||
|
'enum',
|
||||||
|
'const',
|
||||||
|
])
|
||||||
const ANNOTATION_KEYWORDS = new Set(['description', 'title', 'default', 'examples'])
|
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
|
* Test for a realm-agnostic plain JSON record without accepting arrays or
|
||||||
* prototype chain of at most one link (`null`-proto, or any realm's
|
* exotic objects.
|
||||||
* `Object.prototype`, whose own prototype is `null`). Realm-agnostic on
|
* @param value - candidate record from any JavaScript realm.
|
||||||
* purpose: a schema materialized in another realm carries THAT realm's
|
* @returns Whether the value has a plain-object prototype chain.
|
||||||
* `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> {
|
export function isPlainJsonRecord(value: unknown): value is Record<string, unknown> {
|
||||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
||||||
const proto: unknown = Object.getPrototypeOf(value)
|
const proto: unknown = Object.getPrototypeOf(value)
|
||||||
return proto === null || Object.getPrototypeOf(proto) === null
|
return proto === null || Object.getPrototypeOf(proto) === null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether a value is a supported scalar (`enum`/`const` member): string, finite number, boolean, or null. */
|
/** Lossless finite JSON number, excluding negative zero. */
|
||||||
function isStructuredScalar(value: unknown): value is StructuredScalar {
|
function isJsonNumber(value: unknown): value is number {
|
||||||
return value === null || typeof value === 'string' || typeof value === 'boolean'
|
return typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0)
|
||||||
|| (typeof value === 'number' && Number.isFinite(value))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Whether a scalar is valid for one declared schema type. */
|
||||||
* Whether a value is JSON data (annotation payloads only): scalars, arrays, and
|
function scalarMatches(type: JsonSchemaScalarType, value: unknown): value is JsonSchemaScalar {
|
||||||
* object-likes of such values. Realm-agnostic on purpose (no prototype check) —
|
switch (type) {
|
||||||
* the schema may have been materialized from another realm; structural JSON-ness
|
case 'string': return typeof value === 'string'
|
||||||
* is what the wire needs. Cycles are rejected via `seen`.
|
case 'number': return isJsonNumber(value)
|
||||||
*/
|
case 'integer': return isJsonNumber(value) && Number.isInteger(value)
|
||||||
function isJsonData(value: unknown, seen: Set<object>): boolean {
|
case 'boolean': return typeof value === 'boolean'
|
||||||
if (isStructuredScalar(value)) return true
|
case 'null': return value === null
|
||||||
// The scalar check above already returned for null, so `object` here is a real object.
|
/* v8 ignore next -- JsonSchemaScalarType is closed; this retains compile-time exhaustiveness. */
|
||||||
if (typeof value !== 'object') return false
|
default: return assertNever(type, 'JsonSchemaType')
|
||||||
if (seen.has(value)) return false
|
|
||||||
seen.add(value)
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Collect subset violations for one schema node (recursive walk). */
|
/** Collect every violation for one raw schema node. */
|
||||||
function checkSchemaNode(node: unknown, path: string, violations: string[], seen: Set<object>): void {
|
function checkSchemaNode(node: unknown, path: string, violations: string[], seen: Set<object>): void {
|
||||||
if (!isObjectLike(node)) {
|
if (!isPlainJsonRecord(node)) {
|
||||||
violations.push(`${path} must be a schema object`)
|
violations.push(`${path} must be a schema object`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -125,199 +127,258 @@ function checkSchemaNode(node: unknown, path: string, violations: string[], seen
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
seen.add(node)
|
seen.add(node)
|
||||||
|
try {
|
||||||
for (const key of Object.keys(node)) {
|
for (const key of Object.keys(node)) {
|
||||||
if (CONSTRAINT_KEYWORDS.has(key)) continue
|
if (CONSTRAINT_KEYWORDS.has(key)) continue
|
||||||
if (ANNOTATION_KEYWORDS.has(key)) {
|
if (ANNOTATION_KEYWORDS.has(key)) {
|
||||||
if (!isJsonData(node[key], new Set())) violations.push(`${path}.${key} annotation must be JSON data`)
|
try {
|
||||||
continue
|
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 (node.description !== undefined && typeof node.description !== 'string') {
|
||||||
|
violations.push(`${path}.description must be a string`)
|
||||||
|
}
|
||||||
|
if (node.title !== undefined && typeof node.title !== 'string') {
|
||||||
|
violations.push(`${path}.title must be a string`)
|
||||||
}
|
}
|
||||||
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
|
const hasType = Object.hasOwn(node, 'type')
|
||||||
if (typeof type !== 'string' || !(SCHEMA_TYPES as readonly unknown[]).includes(type)) {
|
const hasOneOf = Object.hasOwn(node, 'oneOf')
|
||||||
violations.push(Array.isArray(type)
|
if (hasType && hasOneOf) {
|
||||||
? `${path}.type must be a single type string (type arrays are not supported)`
|
violations.push(`${path} cannot declare both type and oneOf`)
|
||||||
: `${path}.type must be one of ${SCHEMA_TYPES.join('/')}`)
|
return
|
||||||
|
}
|
||||||
|
if (!hasType && !hasOneOf) {
|
||||||
|
for (const key of ['properties', 'required', 'additionalProperties', 'items', 'enum', 'const']) {
|
||||||
|
if (Object.hasOwn(node, key)) violations.push(`${path}.${key} requires type or oneOf`)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasOneOf) {
|
||||||
|
const oneOf = node.oneOf
|
||||||
|
if (!Array.isArray(oneOf) || oneOf.length < 2) {
|
||||||
|
violations.push(`${path}.oneOf must be an array of at least two schemas`)
|
||||||
|
} else {
|
||||||
|
for (let index = 0; index < oneOf.length; index++) {
|
||||||
|
checkSchemaNode(oneOf[index], `${path}.oneOf[${index}]`, violations, seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const key of ['properties', 'required', 'additionalProperties', 'items', 'enum', 'const']) {
|
||||||
|
if (Object.hasOwn(node, key)) violations.push(`${path}.${key} is not supported beside oneOf`)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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('/')}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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 = node.properties
|
||||||
|
if (Object.hasOwn(node, 'properties')) {
|
||||||
|
if (!isPlainJsonRecord(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 required = node.required
|
||||||
|
if (Object.hasOwn(node, 'required')) {
|
||||||
|
if (!Array.isArray(required) || required.some(entry => typeof entry !== 'string')) {
|
||||||
|
violations.push(`${path}.required must be an array of strings`)
|
||||||
|
} else {
|
||||||
|
const declared = isPlainJsonRecord(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`)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'array': {
|
||||||
|
if (Object.hasOwn(node, 'items')) checkSchemaNode(node.items, `${path}.items`, violations, seen)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'string':
|
||||||
|
case 'number':
|
||||||
|
case 'integer':
|
||||||
|
case 'boolean':
|
||||||
|
case 'null': {
|
||||||
|
const allowed = node.enum
|
||||||
|
if (Object.hasOwn(node, 'enum')) {
|
||||||
|
if (!Array.isArray(allowed) || allowed.length === 0 || !allowed.every(entry => scalarMatches(schemaType, entry))) {
|
||||||
|
violations.push(`${path}.enum must be a non-empty array of ${schemaType} values`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.hasOwn(node, 'const') && !scalarMatches(schemaType, node.const)) {
|
||||||
|
violations.push(`${path}.const must be a ${schemaType} value`)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
/* v8 ignore next -- schemaType was narrowed from the closed SCHEMA_TYPES table above. */
|
||||||
|
default: assertNever(schemaType, 'JsonSchemaType')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
seen.delete(node)
|
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}"`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (node.additionalProperties !== undefined && typeof node.additionalProperties !== 'boolean') {
|
|
||||||
violations.push(`${path}.additionalProperties must be a boolean`)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
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
|
* Assert that an arbitrary raw schema uses only the enforced subset.
|
||||||
* and entirely within the enforced subset. Throws {@link OutputSchemaError}
|
* Annotation-only schemas are accepted as the standard unconstrained-JSON
|
||||||
* (`UNSUPPORTED_SCHEMA`) listing EVERY violation; returns (and narrows) on
|
* form; callers that require an object root use {@link assertObjectJsonSchema}.
|
||||||
* success. Call this at the seam boundary, before any child is created.
|
* @param schema - untrusted raw JSON Schema.
|
||||||
* @param schema - the caller-supplied schema (unknown until asserted).
|
* @returns Assertion that the schema belongs to the supported subset.
|
||||||
* @returns nothing — the assertion signature narrows `schema` to
|
|
||||||
* {@link StructuredOutputSchema} in the caller's scope on normal return.
|
|
||||||
*/
|
*/
|
||||||
export function assertSupportedOutputSchema(schema: unknown): asserts schema is StructuredOutputSchema {
|
export function assertSupportedJsonSchema(schema: unknown): asserts schema is JsonSchemaNode {
|
||||||
const violations: string[] = []
|
const violations: string[] = []
|
||||||
checkSchemaNode(schema, 'schema', violations, new Set())
|
checkSchemaNode(schema, 'schema', violations, new Set())
|
||||||
if (violations.length === 0 && (schema as StructuredSchemaNode).type !== 'object') {
|
if (violations.length > 0) throw new JsonSchemaError(violations)
|
||||||
violations.push('schema.type must be "object" (structured output is object-rooted)')
|
|
||||||
}
|
|
||||||
if (violations.length > 0) throw new OutputSchemaError(violations)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Collect violations for one value against an (already asserted) schema node. */
|
/**
|
||||||
function checkValue(node: StructuredSchemaNode, value: unknown, path: string): string[] {
|
* 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 && (schema as JsonSchemaNode).type !== 'object') {
|
||||||
|
violations.push('schema.type must be "object" (structured output is object-rooted)')
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect value violations for one trusted schema node. */
|
||||||
|
function checkValue(node: JsonSchemaNode, value: unknown, path: string): string[] {
|
||||||
|
if (node.oneOf !== undefined) {
|
||||||
|
const matches = node.oneOf.filter(branch => checkValue(branch, value, path).length === 0).length
|
||||||
|
return matches === 1 ? [] : [`"${diagnosticPath(path)}" must match exactly one oneOf branch (matched ${matches})`]
|
||||||
|
}
|
||||||
|
if (node.type === undefined) {
|
||||||
|
return safelyIsJsonValue(value) ? [] : [`"${diagnosticPath(path)}" must be a lossless JSON value`]
|
||||||
|
}
|
||||||
|
|
||||||
switch (node.type) {
|
switch (node.type) {
|
||||||
case 'object': {
|
case 'object': {
|
||||||
if (!isObjectLike(value)) return [`"${path}" must be an object`]
|
if (!isPlainJsonRecord(value)) return [`"${diagnosticPath(path)}" must be an object`]
|
||||||
const violations: string[] = []
|
const violations: string[] = []
|
||||||
const properties = node.properties ?? {}
|
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 ?? []) {
|
for (const key of node.required ?? []) {
|
||||||
if (!Object.hasOwn(value, key) || value[key] === undefined) violations.push(`missing required property "${path}.${key}"`)
|
if (!Object.hasOwn(value, key) || value[key] === undefined) violations.push(`missing required property "${propertyPath(path, key)}"`)
|
||||||
}
|
}
|
||||||
for (const [key, child] of Object.entries(properties)) {
|
for (const [key, child] of Object.entries(properties)) {
|
||||||
if (!Object.hasOwn(value, key) || value[key] === undefined) continue
|
if (!Object.hasOwn(value, key) || value[key] === undefined) continue
|
||||||
violations.push(...checkValue(child, value[key], `${path}.${key}`))
|
violations.push(...checkValue(child, value[key], propertyPath(path, key)))
|
||||||
}
|
}
|
||||||
if (node.additionalProperties === false) {
|
if (node.additionalProperties === false) {
|
||||||
for (const key of Object.keys(value)) {
|
for (const key of Object.keys(value)) {
|
||||||
if (!Object.hasOwn(properties, key)) violations.push(`"${path}.${key}" is not a declared property (additionalProperties: false)`)
|
if (!Object.hasOwn(properties, key)) violations.push(`"${propertyPath(path, key)}" is not a declared property (additionalProperties: false)`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return violations
|
if (violations.length > 0) return violations
|
||||||
|
return safelyIsJsonValue(value) ? [] : [`"${diagnosticPath(path)}" must be a lossless JSON object`]
|
||||||
}
|
}
|
||||||
case 'array': {
|
case 'array': {
|
||||||
if (!Array.isArray(value)) return [`"${path}" must be an array`]
|
if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) return [`"${diagnosticPath(path)}" must be an array`]
|
||||||
if (!node.items) return []
|
|
||||||
const items = node.items
|
const items = node.items
|
||||||
return value.flatMap((entry, index) => checkValue(items, entry, `${path}[${index}]`))
|
const violations = items === undefined
|
||||||
|
? []
|
||||||
|
: value.flatMap((entry, index) => checkValue(items, entry, `${path}[${index}]`))
|
||||||
|
if (violations.length > 0) return violations
|
||||||
|
return safelyIsJsonValue(value) ? [] : [`"${diagnosticPath(path)}" must be a dense lossless JSON array`]
|
||||||
}
|
}
|
||||||
case 'string': {
|
case 'string': {
|
||||||
if (typeof value !== 'string') return [`"${path}" must be a string`]
|
if (typeof value !== 'string') return [`"${diagnosticPath(path)}" must be a string`]
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'number': {
|
case 'number': {
|
||||||
if (typeof value !== 'number' || !Number.isFinite(value)) return [`"${path}" must be a finite number`]
|
if (typeof value !== 'number') return [`"${diagnosticPath(path)}" must be a number`]
|
||||||
|
if (!isJsonNumber(value)) return [`"${diagnosticPath(path)}" must be a finite JSON number`]
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'integer': {
|
case 'integer': {
|
||||||
if (typeof value !== 'number' || !Number.isInteger(value)) return [`"${path}" must be an integer`]
|
if (!isJsonNumber(value) || !Number.isInteger(value)) return [`"${diagnosticPath(path)}" must be an integer`]
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'boolean': {
|
case 'boolean': {
|
||||||
if (typeof value !== 'boolean') return [`"${path}" must be a boolean`]
|
if (typeof value !== 'boolean') return [`"${diagnosticPath(path)}" must be a boolean`]
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'null': {
|
case 'null': {
|
||||||
if (value !== null) return [`"${path}" must be null`]
|
if (value !== null) return [`"${diagnosticPath(path)}" must be null`]
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
default:
|
default: return assertNever(node.type, 'JsonSchemaType')
|
||||||
return assertNever(node.type, 'validateStructuredValue')
|
|
||||||
}
|
}
|
||||||
// Scalar constraint checks, shared by every scalar branch above.
|
if (node.enum !== undefined && !node.enum.includes(value)) {
|
||||||
if (node.enum && !node.enum.includes(value)) {
|
return [`"${diagnosticPath(path)}" must be one of ${JSON.stringify(node.enum)}`]
|
||||||
return [`"${path}" must be one of ${JSON.stringify(node.enum)}`]
|
|
||||||
}
|
}
|
||||||
if ('const' in node && value !== node.const) {
|
if (Object.hasOwn(node, 'const') && value !== node.const) {
|
||||||
return [`"${path}" must be ${JSON.stringify(node.const)}`]
|
return [`"${diagnosticPath(path)}" must be ${JSON.stringify(node.const)}`]
|
||||||
}
|
}
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate a value against an (already {@link assertSupportedOutputSchema}-
|
* Validate a candidate value against an asserted raw schema. The function is
|
||||||
* asserted) schema. Returns human-readable, path-qualified violation messages
|
* total for arbitrary values and returns path-qualified violations.
|
||||||
* — empty means valid. Total: never throws, however malformed the value.
|
* @param schema - a schema accepted by {@link assertSupportedJsonSchema}.
|
||||||
* @param schema - the asserted schema to check against.
|
* @param value - the candidate JSON value.
|
||||||
* @param value - the candidate value (e.g. parsed tool-call arguments).
|
* @param path - root label used in diagnostics.
|
||||||
* @returns every violation found, in walk order (empty = valid).
|
* @returns All violations in walk order; empty means valid.
|
||||||
*/
|
*/
|
||||||
export function validateStructuredValue(schema: StructuredOutputSchema, value: unknown): string[] {
|
export function validateJsonSchemaValue(schema: JsonSchemaNode, value: unknown, path = 'value'): string[] {
|
||||||
return checkValue(schema, value, 'value')
|
return checkValue(schema, value, path)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,173 +1,314 @@
|
|||||||
/** Typed tool-parameter DSL with argument inference and JSON Schema output. @module dsh-tools/schema */
|
/** Unified JSON-value schema DSL, inference, compilation, and typed tool helper. @module dsh-tools/schema */
|
||||||
|
|
||||||
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
|
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||||
|
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||||
import type { ToolDefinition, ToolExecuteReturn, ToolRunContext, ToolResult } from './index.ts'
|
import type { ToolDefinition, ToolExecuteReturn, ToolRunContext, ToolResult } from './index.ts'
|
||||||
|
import { assertSupportedJsonSchema, isPlainJsonRecord, JsonSchemaError, validateJsonSchemaValue } from './json-schema.ts'
|
||||||
|
import type { JsonSchemaNode, JsonSchemaScalar, ObjectJsonSchema } from './json-schema.ts'
|
||||||
import type { ToolCallView, ToolResultView } from './presentation.ts'
|
import type { ToolCallView, ToolResultView } from './presentation.ts'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
/** Annotation keywords shared by every author-facing schema node. */
|
||||||
// SchemaSpec — the author-facing per-property type
|
export interface ValueSchemaAnnotations {
|
||||||
// ---------------------------------------------------------------------------
|
/** Human-readable description projected into JSON Schema and generated types. */
|
||||||
|
|
||||||
/** 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. */
|
|
||||||
description?: string
|
description?: string
|
||||||
/** Enum of allowed values (strings only). */
|
/** Human-readable title projected into JSON Schema. */
|
||||||
enum?: string[]
|
title?: string
|
||||||
/**
|
/** Non-validating default annotation; it must be lossless JSON data. */
|
||||||
* Model-visible JSON Schema default annotation. Validation does not apply it;
|
default?: JsonValue
|
||||||
* dynamic tool mounts may supply it even though first-party definitions do not.
|
/** Non-validating examples annotation; it must be lossless JSON data. */
|
||||||
*/
|
examples?: JsonValue
|
||||||
default?: unknown
|
}
|
||||||
/** Nested properties for type: 'object'. */
|
|
||||||
properties?: SchemaSpec
|
/** String value schema with type-correct literal constraints. */
|
||||||
/** Items schema for type: 'array'. */
|
export interface StringValueSchemaSpec extends ValueSchemaAnnotations {
|
||||||
items?: SchemaProp
|
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
|
* Explicit object value schema. Openness is mandatory so a nested or output
|
||||||
* {@link SchemaProp}. Required-ness is a per-property boolean (`required:
|
* object never acquires an accidental JSON Schema default.
|
||||||
* true`), not a separate array.
|
|
||||||
*/
|
*/
|
||||||
export type SchemaSpec = Record<string, SchemaProp>
|
export interface ObjectValueSchemaSpec extends ValueSchemaAnnotations {
|
||||||
|
type: 'object'
|
||||||
|
properties?: ParameterSchemaSpec
|
||||||
|
additionalProperties: boolean
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
/** Author-only unconstrained lossless JSON node. */
|
||||||
// InferArgs — type-level mapping from SchemaSpec to TS argument type
|
export interface JsonValueSchemaSpec extends ValueSchemaAnnotations {
|
||||||
// ---------------------------------------------------------------------------
|
type: 'json'
|
||||||
|
}
|
||||||
|
|
||||||
/** Map a {@link SchemaType} to its TS primitive type. */
|
/** Exact-one union schema; at least two branches are required. */
|
||||||
type TypeOf<T extends SchemaType> =
|
export interface OneOfValueSchemaSpec extends ValueSchemaAnnotations {
|
||||||
T extends 'string' ? string :
|
oneOf: readonly [ValueSchemaSpec, ValueSchemaSpec, ...ValueSchemaSpec[]]
|
||||||
T extends 'number' ? number :
|
}
|
||||||
T extends 'boolean' ? boolean :
|
|
||||||
T extends 'object' ? Record<string, unknown> :
|
/** One author-facing schema for any lossless JSON value root. */
|
||||||
T extends 'array' ? unknown[] :
|
export type ValueSchemaSpec =
|
||||||
never
|
| 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 = Record<string, ParameterPropertySpec>
|
||||||
|
|
||||||
|
/** 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. */
|
/** Flatten an intersection into one object type for readable hovers. */
|
||||||
type Simplify<T> = { [K in keyof T]: T[K] } & {}
|
type Simplify<T> = { [K in keyof T]: T[K] } & {}
|
||||||
|
|
||||||
/** Keys of `S` whose prop is marked `required: true`. */
|
/** Keys of a property map marked `required: true`. */
|
||||||
type RequiredKeys<S extends SchemaSpec> =
|
type RequiredKeys<S extends ParameterSchemaSpec> = {
|
||||||
{ [K in keyof S]: S[K] extends { required: true } ? K : never }[keyof S]
|
[K in keyof S]: S[K] extends { required: true } ? K : never
|
||||||
|
}[keyof S]
|
||||||
|
|
||||||
/**
|
/** Infer the declared value of one parameter property without key optionality. */
|
||||||
* The VALUE type of one {@link SchemaProp} — optionality is handled at the
|
type InferProperty<P extends ParameterPropertySpec> = P extends ValueSchemaSpec ? InferValue<P> : never
|
||||||
* 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']>
|
|
||||||
|
|
||||||
/**
|
/** Infer an implicit property map into required and optional object keys. */
|
||||||
* Infer the TS argument type for a complete {@link SchemaSpec}.
|
type InferProperties<S extends ParameterSchemaSpec> = Simplify<
|
||||||
*
|
& { [K in RequiredKeys<S>]: InferProperty<S[K]> }
|
||||||
* Properties marked `required: true` are required keys; all others are
|
& { [K in Exclude<keyof S, RequiredKeys<S>>]?: InferProperty<S[K]> }
|
||||||
* 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 an explicit object node, including its declared openness. */
|
||||||
// Runtime conversion: SchemaSpec → JSON Schema
|
type InferObject<S extends ObjectValueSchemaSpec> =
|
||||||
// ---------------------------------------------------------------------------
|
S extends { properties: infer P extends ParameterSchemaSpec }
|
||||||
|
? S['additionalProperties'] extends true
|
||||||
|
? InferProperties<P> & Record<string, JsonValue>
|
||||||
|
: InferProperties<P>
|
||||||
|
: 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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a single {@link SchemaProp} to its JSON Schema `properties` entry.
|
* Infer the TypeScript value accepted by an author-facing value schema.
|
||||||
* The per-property `required` flag is collected; the caller builds the
|
* Output schemas may therefore infer object, array, scalar, or null roots.
|
||||||
* top-level `required` array.
|
|
||||||
*/
|
*/
|
||||||
function propToJsonSchema(prop: SchemaProp): { schema: Record<string, unknown>; required: boolean } {
|
export type InferValue<S extends ValueSchemaSpec> =
|
||||||
const result: Record<string, unknown> = { type: prop.type }
|
S extends StringValueSchemaSpec ? InferScalar<S, string> :
|
||||||
if (prop.description) result.description = prop.description
|
S extends NumberValueSchemaSpec | IntegerValueSchemaSpec ? InferScalar<S, number> :
|
||||||
if (prop.enum) result.enum = prop.enum
|
S extends BooleanValueSchemaSpec ? InferScalar<S, boolean> :
|
||||||
if (prop.default !== undefined) result.default = prop.default
|
S extends NullValueSchemaSpec ? null :
|
||||||
|
S extends ArrayValueSchemaSpec
|
||||||
|
? S extends { items: infer I extends ValueSchemaSpec } ? InferValue<I>[] : JsonValue[]
|
||||||
|
: S extends ObjectValueSchemaSpec ? InferObject<S> :
|
||||||
|
S extends JsonValueSchemaSpec ? JsonValue :
|
||||||
|
S extends OneOfValueSchemaSpec ? InferValue<S['oneOf'][number]> :
|
||||||
|
never
|
||||||
|
|
||||||
const required = prop.required === true
|
/** Infer the TypeScript argument object for an implicit parameter schema. */
|
||||||
|
export type InferArgs<S extends ParameterSchemaSpec> = InferProperties<S>
|
||||||
|
|
||||||
if (prop.type === 'object' && prop.properties) {
|
const ANNOTATION_KEYS = ['description', 'title', 'default', 'examples'] as const
|
||||||
const nested = schemaSpecToJsonSchema(prop.properties)
|
|
||||||
result.properties = nested.properties
|
/** Throw one author-schema violation through the shared schema error type. */
|
||||||
if (nested.required && nested.required.length > 0) {
|
function authorError(message: string): never {
|
||||||
result.required = nested.required
|
throw new JsonSchemaError([message])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compile one implicit property map, collecting per-property requiredness. */
|
||||||
|
function compilePropertyMap(
|
||||||
|
input: unknown,
|
||||||
|
path: string,
|
||||||
|
seen: Set<object>,
|
||||||
|
): { properties: Record<string, JsonSchemaNode>; required?: string[] } {
|
||||||
|
if (!isPlainJsonRecord(input)) authorError(`${path} must be an object of value schemas`)
|
||||||
|
if (seen.has(input)) authorError(`${path} is circular`)
|
||||||
|
seen.add(input)
|
||||||
|
try {
|
||||||
|
const properties: Record<string, JsonSchemaNode> = {}
|
||||||
|
const required: string[] = []
|
||||||
|
for (const [key, property] of Object.entries(input)) {
|
||||||
|
if (!isPlainJsonRecord(property)) authorError(`${path}.${key} must be a value schema object`)
|
||||||
|
if (Object.hasOwn(property, 'required') && property.required !== true) {
|
||||||
|
authorError(`${path}.${key}.required must be true when present`)
|
||||||
|
}
|
||||||
|
properties[key] = compileValueSchema(property, `${path}.${key}`, seen, true)
|
||||||
|
if (property.required === true) required.push(key)
|
||||||
}
|
}
|
||||||
|
return required.length > 0 ? { properties, required } : { properties }
|
||||||
|
} finally {
|
||||||
|
seen.delete(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prop.type === 'array' && prop.items) {
|
|
||||||
const { schema: itemsSchema } = propToJsonSchema(prop.items)
|
|
||||||
result.items = itemsSchema
|
|
||||||
}
|
|
||||||
|
|
||||||
return { schema: result, required }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The return type of {@link schemaSpecToJsonSchema}. */
|
/** Compile one author node without applying any consumer root restriction. */
|
||||||
export interface JsonSchemaObject {
|
function compileValueSchema(
|
||||||
type: 'object'
|
input: unknown,
|
||||||
properties: Record<string, unknown>
|
path: string,
|
||||||
required?: string[]
|
seen: Set<object>,
|
||||||
|
allowRequired = false,
|
||||||
|
): JsonSchemaNode {
|
||||||
|
if (!isPlainJsonRecord(input)) authorError(`${path} must be a value schema object`)
|
||||||
|
if (seen.has(input)) authorError(`${path} is circular`)
|
||||||
|
seen.add(input)
|
||||||
|
try {
|
||||||
|
const authorKeys = [...ANNOTATION_KEYS, ...(allowRequired ? ['required'] : [])]
|
||||||
|
const node: JsonSchemaNode = {}
|
||||||
|
|
||||||
|
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 (!Array.isArray(input.oneOf)) authorError(`${path}.oneOf must be an array of at least two value schemas`)
|
||||||
|
node.oneOf = input.oneOf.map((branch, index) => compileValueSchema(branch, `${path}.oneOf[${index}]`, seen))
|
||||||
|
copyAnnotations(input, node)
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (input.type) {
|
||||||
|
case 'json':
|
||||||
|
assertAuthorKeys(input, path, [...authorKeys, 'type'])
|
||||||
|
copyAnnotations(input, node)
|
||||||
|
return node
|
||||||
|
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')) {
|
||||||
|
const compiled = compilePropertyMap(input.properties, `${path}.properties`, seen)
|
||||||
|
node.properties = compiled.properties
|
||||||
|
if (compiled.required !== undefined) node.required = compiled.required
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
case 'array':
|
||||||
|
assertAuthorKeys(input, path, [...authorKeys, 'type', 'items'])
|
||||||
|
node.type = 'array'
|
||||||
|
copyAnnotations(input, node)
|
||||||
|
if (Object.hasOwn(input, 'items')) node.items = compileValueSchema(input.items, `${path}.items`, seen)
|
||||||
|
return node
|
||||||
|
case 'string':
|
||||||
|
case 'number':
|
||||||
|
case 'integer':
|
||||||
|
case 'boolean':
|
||||||
|
case 'null':
|
||||||
|
assertAuthorKeys(input, path, [...authorKeys, 'type', 'enum', 'const'])
|
||||||
|
node.type = input.type
|
||||||
|
copyAnnotations(input, node)
|
||||||
|
if (Object.hasOwn(input, 'enum')) {
|
||||||
|
node.enum = Array.isArray(input.enum)
|
||||||
|
? Array.from(input.enum as unknown[], entry => entry as JsonSchemaScalar)
|
||||||
|
: input.enum as JsonSchemaScalar[]
|
||||||
|
}
|
||||||
|
if (Object.hasOwn(input, 'const')) node.const = input.const as JsonSchemaScalar
|
||||||
|
return node
|
||||||
|
default:
|
||||||
|
return authorError(`${path}.type must be string/number/integer/boolean/null/array/object/json, or use oneOf`)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
seen.delete(input)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a {@link SchemaSpec} to standard JSON Schema (`type: 'object'`,
|
* Compile one author-facing value schema to the enforced raw JSON Schema
|
||||||
* `properties`, `required` array).
|
* subset. The author-only `json` node becomes an annotation-only schema.
|
||||||
*
|
* @param spec - schema for any JSON-value root.
|
||||||
* This is a plain function — no schemastery or other framework dependency.
|
* @returns The asserted raw schema projection.
|
||||||
* @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 {
|
export function valueSchemaSpecToJsonSchema(spec: ValueSchemaSpec): JsonSchemaNode {
|
||||||
const properties: Record<string, unknown> = {}
|
const schema = compileValueSchema(spec, 'schema', new Set())
|
||||||
const required: string[] = []
|
assertSupportedJsonSchema(schema)
|
||||||
|
return schema
|
||||||
|
}
|
||||||
|
|
||||||
for (const [key, prop] of Object.entries(spec)) {
|
/**
|
||||||
const { schema, required: isRequired } = propToJsonSchema(prop)
|
* Compile the implicit open parameter object into raw JSON Schema.
|
||||||
properties[key] = schema
|
* @param spec - per-property parameter definitions.
|
||||||
if (isRequired) required.push(key)
|
* @returns An object-rooted raw schema with no implicit-root openness override.
|
||||||
}
|
*/
|
||||||
|
export function parameterSchemaSpecToJsonSchema(spec: ParameterSchemaSpec): ParameterJsonSchema {
|
||||||
const result: JsonSchemaObject = {
|
const compiled = compilePropertyMap(spec, 'parameters', new Set())
|
||||||
|
const schema: ParameterJsonSchema = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties,
|
properties: compiled.properties,
|
||||||
|
...(compiled.required === undefined ? {} : { required: compiled.required }),
|
||||||
}
|
}
|
||||||
if (required.length > 0) result.required = required
|
assertSupportedJsonSchema(schema)
|
||||||
|
return schema
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
/** Invalid model-generated arguments for a typed tool. */
|
||||||
// Runtime validation: model-generated args ↔ SchemaSpec
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
export class ToolArgsError extends HarnessError {
|
export class ToolArgsError extends HarnessError {
|
||||||
/** The individual violation messages, in declaration order. */
|
/** Individual violations in schema-walk order. */
|
||||||
readonly violations: string[]
|
readonly violations: string[]
|
||||||
|
|
||||||
constructor(violations: string[]) {
|
constructor(violations: string[]) {
|
||||||
@@ -177,152 +318,63 @@ 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
|
* Validate model-generated arguments against an implicit parameter schema.
|
||||||
* list of human-readable violation messages (empty = valid). Total — never
|
* @param spec - declared parameter schema.
|
||||||
* throws, regardless of how malformed `args` is.
|
* @param args - candidate arguments, however malformed.
|
||||||
*
|
* @returns Path-qualified violations; empty means valid.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export function validateArgs(spec: SchemaSpec, args: unknown): string[] {
|
export function validateArgs(spec: ParameterSchemaSpec, args: unknown): string[] {
|
||||||
return checkSpec(spec, args, '')
|
return validateJsonSchemaValue(parameterSchemaSpecToJsonSchema(spec), args, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// defineTool — typed helper for first-party plugin authors
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Options for {@link defineTool}. */
|
/** Options for {@link defineTool}. */
|
||||||
export interface DefineToolOptions<S extends SchemaSpec> {
|
export interface DefineToolOptions<S extends ParameterSchemaSpec> {
|
||||||
/** Tool name (must be unique). */
|
/** Tool name (must be unique). */
|
||||||
readonly name: string
|
readonly name: string
|
||||||
/** Human-readable description sent to the model. */
|
/** Human-readable description sent to the model. */
|
||||||
readonly description: string
|
readonly description: string
|
||||||
/**
|
/** Per-property parameter schema compiled to an implicit open object root. */
|
||||||
* Parameter schema using the per-property-required DSL. Converted to
|
|
||||||
* standard JSON Schema at runtime.
|
|
||||||
*/
|
|
||||||
readonly parameters: S
|
readonly parameters: S
|
||||||
/**
|
/** Optional positive cooperative timeout budget in milliseconds. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
readonly timeoutMs?: number
|
readonly timeoutMs?: number
|
||||||
/**
|
/**
|
||||||
* Optional pure synchronous classifier for sibling overlap. It receives typed
|
* Pure classifier for sibling overlap.
|
||||||
* arguments after soft validation; invalid input returns `false` without
|
|
||||||
* invoking it. See {@link ToolDefinition.isConcurrencySafe}.
|
|
||||||
* @param args - typed validated arguments.
|
* @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
|
isConcurrencySafe?(args: InferArgs<S>): boolean
|
||||||
/**
|
/**
|
||||||
* Tool execution function. `args` is typed as {@link InferArgs<S>} — zero
|
* Execute the tool after argument validation.
|
||||||
* casts needed. Returns either a bare {@link ContentBlock}`[]` (model-facing
|
* @param args - typed validated arguments.
|
||||||
* content only) or a `{ content, meta }` object to also attach a tool-private
|
* @param exec - execution identity, caller, cancellation, and nesting data.
|
||||||
* presentation payload (see {@link ToolExecuteReturn}).
|
* @returns Model-facing content and optional presentation metadata.
|
||||||
*/
|
*/
|
||||||
execute(args: InferArgs<S>, exec: ToolRunContext): Promise<ToolExecuteReturn>
|
execute(args: InferArgs<S>, exec: ToolRunContext): Promise<ToolExecuteReturn>
|
||||||
/**
|
/**
|
||||||
* Optional: how to present the PENDING state of one call in a UI (an editor
|
* Pure pending-state presenter.
|
||||||
* tool-call card, a CLI log line). `args` is the typed, schema-validated
|
* @param args - typed validated arguments.
|
||||||
* argument shape — zero casts. Pure and side-effect-free: a UI may call it
|
* @returns Tool-owned render intent, or `undefined` for the generic card.
|
||||||
* 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}.
|
|
||||||
*/
|
*/
|
||||||
presentCall?(args: InferArgs<S>): ToolCallView | undefined
|
presentCall?(args: InferArgs<S>): ToolCallView | undefined
|
||||||
/**
|
/**
|
||||||
* Optional: how to present the COMPLETED state, given the typed `args` and the
|
* Pure completed-state presenter.
|
||||||
* `result`. Use it to reformat result content for a UI distinctly from the
|
* @param args - typed validated arguments.
|
||||||
* model-facing text (e.g. a fenced ```console block). Pure and side-effect-
|
* @param result - final model-facing tool result.
|
||||||
* free for the same replay reason. See {@link ToolResultView}.
|
* @returns Tool-owned render intent, or `undefined` for the generic card.
|
||||||
*/
|
*/
|
||||||
presentResult?(args: InferArgs<S>, result: ToolResult): ToolResultView | undefined
|
presentResult?(args: InferArgs<S>, result: ToolResult): ToolResultView | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Define a first-party tool whose execution and presentation arguments are
|
* Define a first-party tool with inferred arguments and strict execution
|
||||||
* inferred from its per-property schema. Raw JSON-Schema definitions remain
|
* validation. Replay-only presenters validate softly and fall back to generic
|
||||||
* valid inputs to {@link ToolRegistry.register}; this helper is authoring sugar.
|
* rendering for obsolete logged arguments.
|
||||||
* @param options - the tool's name, description, typed parameter schema,
|
* @param options - typed definition and optional presenters.
|
||||||
* execute body, and optional presenters.
|
* @returns A registry-ready definition.
|
||||||
* @returns a registry-ready definition with strict execution validation and
|
|
||||||
* soft presenter and classifier validation for replay compatibility.
|
|
||||||
*/
|
*/
|
||||||
export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>): ToolDefinition {
|
export function defineTool<S extends ParameterSchemaSpec>(options: DefineToolOptions<S>): ToolDefinition {
|
||||||
// Object-literal execute methods don't use `this`; the reference is safe.
|
// Object-literal methods do not use `this`; retaining references is safe.
|
||||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||||
const userExecute = options.execute
|
const userExecute = options.execute
|
||||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||||
@@ -334,41 +386,34 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
|
|||||||
if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) {
|
if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) {
|
||||||
throw new Error(`defineTool(${options.name}): timeoutMs must be a positive finite number`)
|
throw new Error(`defineTool(${options.name}): timeoutMs must be a positive finite number`)
|
||||||
}
|
}
|
||||||
|
const parameters = parameterSchemaSpecToJsonSchema(options.parameters)
|
||||||
|
const validate = (args: unknown): string[] => validateJsonSchemaValue(parameters, args, '')
|
||||||
const tool: ToolDefinition = {
|
const tool: ToolDefinition = {
|
||||||
name: options.name,
|
name: options.name,
|
||||||
description: options.description,
|
description: options.description,
|
||||||
parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>,
|
parameters: parameters as unknown as Record<string, unknown>,
|
||||||
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
|
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
|
||||||
async execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn> {
|
async execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn> {
|
||||||
// Validate the model-generated args before the typed body runs. On
|
const violations = validate(args)
|
||||||
// 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)
|
|
||||||
if (violations.length > 0) throw new ToolArgsError(violations)
|
if (violations.length > 0) throw new ToolArgsError(violations)
|
||||||
return userExecute(args as InferArgs<S>, exec)
|
return userExecute(args as InferArgs<S>, exec)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
// 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) {
|
if (userPresentCall) {
|
||||||
tool.presentCall = (args: unknown): ToolCallView | undefined => {
|
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>)
|
return userPresentCall(args as InferArgs<S>)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (userPresentResult) {
|
if (userPresentResult) {
|
||||||
tool.presentResult = (args: unknown, result: ToolResult): ToolResultView | undefined => {
|
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)
|
return userPresentResult(args as InferArgs<S>, result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Invalid arguments fail closed without invoking the typed classifier.
|
|
||||||
if (userIsConcurrencySafe) {
|
if (userIsConcurrencySafe) {
|
||||||
tool.isConcurrencySafe = (args: unknown): boolean => {
|
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>)
|
return userIsConcurrencySafe(args as InferArgs<S>)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
|
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||||
|
import { assertSupportedJsonSchema } from './json-schema.ts'
|
||||||
|
import type { JsonSchemaScalar } from './json-schema.ts'
|
||||||
|
|
||||||
/** Property names that are valid bare TS identifiers; anything else is quoted. */
|
/** Property names that are valid bare TS identifiers; anything else is quoted. */
|
||||||
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
||||||
@@ -30,47 +32,72 @@ function docLines(description: unknown, indent: number): string[] {
|
|||||||
return [`${pad(indent)}/** ${collapsed.replaceAll('*/', String.raw`*\/`)} */`]
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parenthesize a union or object intersection before applying `[]`. */
|
||||||
|
function arrayItem(type: string): string {
|
||||||
|
return type.includes('|') || type.includes('&') ? `(${type})[]` : `${type}[]`
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map one JSON-Schema node to a TypeScript type literal. Handles exactly the
|
* Map one enforced JSON-Schema node to a TypeScript type literal. Supports
|
||||||
* subset the `defineTool` DSL emits — `object` (`properties` + `required`),
|
* every unified schema construct and returns `unknown` for malformed or
|
||||||
* `string` (with `enum` → a literal union), `number`, `boolean`, `array`
|
* unsupported inputs without throwing.
|
||||||
* (`items`) — and returns `unknown` for anything else, without throwing.
|
|
||||||
* @param schema - the JSON-Schema node (any shape; hostile inputs degrade).
|
* @param schema - the JSON-Schema node (any shape; hostile inputs degrade).
|
||||||
* @param indent - the indentation level for nested object members.
|
* @param indent - the indentation level for nested object members.
|
||||||
* @returns the TS type text (multi-line for objects with properties).
|
* @returns the TS type text (multi-line for objects with properties).
|
||||||
*/
|
*/
|
||||||
export function jsonSchemaToTs(schema: unknown, indent = 0): string {
|
export function jsonSchemaToTs(schema: unknown, indent = 0): string {
|
||||||
if (typeof schema !== 'object' || schema === null) return 'unknown'
|
try {
|
||||||
|
assertSupportedJsonSchema(schema)
|
||||||
|
} catch {
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
const node = schema as Record<string, unknown>
|
const node = schema as Record<string, unknown>
|
||||||
|
if (Object.hasOwn(node, 'oneOf')) {
|
||||||
|
return (node.oneOf as unknown[]).map(branch => jsonSchemaToTs(branch, indent)).join(' | ')
|
||||||
|
}
|
||||||
|
if (!Object.hasOwn(node, 'type')) return 'JsonValue'
|
||||||
switch (node.type) {
|
switch (node.type) {
|
||||||
case 'string': {
|
case 'string': return renderConstrainedScalar(node, 'string')
|
||||||
if (Array.isArray(node.enum) && node.enum.length > 0 && node.enum.every(value => typeof value === 'string')) {
|
case 'number': return renderConstrainedScalar(node, 'number')
|
||||||
return node.enum.map(value => JSON.stringify(value)).join(' | ')
|
case 'integer': return renderConstrainedScalar(node, 'integer')
|
||||||
}
|
case 'boolean': return renderConstrainedScalar(node, 'boolean')
|
||||||
return 'string'
|
case 'null': return renderConstrainedScalar(node, 'null')
|
||||||
}
|
|
||||||
case 'number': return 'number'
|
|
||||||
case 'boolean': return 'boolean'
|
|
||||||
case 'array': {
|
case 'array': {
|
||||||
const item = jsonSchemaToTs(node.items, indent)
|
return arrayItem(Object.hasOwn(node, 'items') ? jsonSchemaToTs(node.items, indent) : 'JsonValue')
|
||||||
// Parenthesize a union item type so `('a' | 'b')[]` parses as intended.
|
|
||||||
return item.includes('|') ? `(${item})[]` : `${item}[]`
|
|
||||||
}
|
}
|
||||||
case 'object': {
|
case 'object': {
|
||||||
const properties = node.properties
|
const properties = node.properties
|
||||||
if (typeof properties !== 'object' || properties === null) return 'Record<string, unknown>'
|
const open = node.additionalProperties !== false
|
||||||
|
if (properties === undefined) return open ? 'Record<string, JsonValue>' : 'Record<string, never>'
|
||||||
const entries = Object.entries(properties as Record<string, unknown>)
|
const entries = Object.entries(properties as Record<string, unknown>)
|
||||||
if (entries.length === 0) return 'Record<string, unknown>'
|
if (entries.length === 0) return open ? 'Record<string, JsonValue>' : 'Record<string, never>'
|
||||||
const required = new Set(Array.isArray(node.required) ? node.required.filter(name => typeof name === 'string') : [])
|
const required = new Set(node.required as string[] | undefined)
|
||||||
const lines: string[] = ['{']
|
const lines: string[] = ['{']
|
||||||
for (const [name, prop] of entries) {
|
for (const [name, prop] of entries) {
|
||||||
const description = typeof prop === 'object' && prop !== null ? (prop as Record<string, unknown>).description : undefined
|
const description = (prop as Record<string, unknown>).description
|
||||||
lines.push(...docLines(description, indent + 1))
|
lines.push(...docLines(description, indent + 1))
|
||||||
lines.push(`${pad(indent + 1)}${renderKey(name)}${required.has(name) ? '' : '?'}: ${jsonSchemaToTs(prop, indent + 1)};`)
|
lines.push(`${pad(indent + 1)}${renderKey(name)}${required.has(name) ? '' : '?'}: ${jsonSchemaToTs(prop, indent + 1)};`)
|
||||||
}
|
}
|
||||||
lines.push(`${pad(indent)}}`)
|
lines.push(`${pad(indent)}}`)
|
||||||
return lines.join('\n')
|
const declared = lines.join('\n')
|
||||||
|
return open ? `${declared} & Record<string, JsonValue>` : declared
|
||||||
}
|
}
|
||||||
|
/* v8 ignore next -- assertSupportedJsonSchema narrowed this closed type union. */
|
||||||
default: return 'unknown'
|
default: return 'unknown'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,5 +133,6 @@ export function renderToolsSdk(schemas: ToolSchema[]): string {
|
|||||||
const declaration = members.length > 0
|
const declaration = members.length > 0
|
||||||
? `declare const tools: {\n${members.join('\n')}\n}`
|
? `declare const tools: {\n${members.join('\n')}\n}`
|
||||||
: 'declare const tools: {}'
|
: 'declare const tools: {}'
|
||||||
return `${SDK_INSTRUCTIONS}\n\n\`\`\`ts\n${declaration}\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\`\`\``
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,304 +1,326 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
assertSupportedOutputSchema,
|
assertObjectJsonSchema,
|
||||||
OutputSchemaError,
|
assertSupportedJsonSchema,
|
||||||
validateStructuredValue,
|
JsonSchemaError,
|
||||||
type StructuredOutputSchema,
|
validateJsonSchemaValue,
|
||||||
} from '../src/json-schema.ts'
|
type JsonSchemaNode,
|
||||||
|
type ObjectJsonSchema,
|
||||||
|
} from '../src/index.ts'
|
||||||
|
|
||||||
/** Assert-and-narrow helper: the asserted schema, typed. */
|
function asserted(schema: unknown): JsonSchemaNode {
|
||||||
function asserted(schema: unknown): StructuredOutputSchema {
|
assertSupportedJsonSchema(schema)
|
||||||
assertSupportedOutputSchema(schema)
|
|
||||||
return schema
|
return schema
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The violations OutputSchemaError carries for a bad schema (throws if it passes). */
|
function assertedObject(schema: unknown): ObjectJsonSchema {
|
||||||
function violationsOf(schema: unknown): string[] {
|
assertObjectJsonSchema(schema)
|
||||||
try {
|
return schema
|
||||||
assertSupportedOutputSchema(schema)
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (error instanceof OutputSchemaError) return error.violations
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
throw new Error('expected the schema to be rejected')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('assertSupportedOutputSchema', () => {
|
function violationsOf(schema: unknown, objectRoot = false): string[] {
|
||||||
it('accepts a representative subset schema (all supported keywords)', () => {
|
try {
|
||||||
const schema = asserted({
|
if (objectRoot) assertObjectJsonSchema(schema)
|
||||||
type: 'object',
|
else assertSupportedJsonSchema(schema)
|
||||||
description: 'a finding',
|
} catch (error: unknown) {
|
||||||
title: 'Finding',
|
if (error instanceof JsonSchemaError) return error.violations
|
||||||
properties: {
|
throw error
|
||||||
file: { type: 'string', description: 'path' },
|
}
|
||||||
line: { type: 'integer' },
|
throw new Error('expected schema rejection')
|
||||||
severity: { type: 'string', enum: ['low', 'high'] },
|
}
|
||||||
kind: { type: 'string', const: 'bug' },
|
|
||||||
score: { type: 'number' },
|
describe('the enforced raw JSON Schema subset', () => {
|
||||||
confirmed: { type: 'boolean' },
|
it('accepts every JSON root and every supported node', () => {
|
||||||
parent: { type: 'null' },
|
for (const schema of [
|
||||||
tags: { type: 'array', items: { type: 'string' } },
|
{ type: 'string' },
|
||||||
nested: {
|
{ type: 'number' },
|
||||||
type: 'object',
|
{ type: 'integer' },
|
||||||
properties: { x: { type: 'number', default: 3, examples: [1, 2] } },
|
{ type: 'boolean' },
|
||||||
additionalProperties: false,
|
{ 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'],
|
{ oneOf: [{ type: 'string' }, { type: 'number' }] },
|
||||||
additionalProperties: true,
|
{ description: 'any JSON', title: 'JSON', default: null, examples: [1, 'x'] },
|
||||||
})
|
]) {
|
||||||
expect(schema.type).toBe('object')
|
expect(() => { assertSupportedJsonSchema(schema) }, JSON.stringify(schema)).not.toThrow()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects a non-object root (scalar/array-rooted schemas)', () => {
|
it('retains an object-root guard only at consumers that need it', () => {
|
||||||
expect(violationsOf({ type: 'string' })).toEqual(['schema.type must be "object" (structured output is object-rooted)'])
|
expect(assertedObject({ type: 'object' }).type).toBe('object')
|
||||||
expect(violationsOf({ type: 'array', items: { type: 'string' } }))
|
for (const schema of [{}, { type: 'string' }, { type: 'array' }, { oneOf: [{ type: 'string' }, { type: 'null' }] }]) {
|
||||||
.toContain('schema.type must be "object" (structured output is object-rooted)')
|
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', () => {
|
it('rejects non-schema nodes, unknown types, and type arrays', () => {
|
||||||
expect(violationsOf('nope')).toEqual(['schema must be a schema object'])
|
|
||||||
expect(violationsOf(null)).toEqual(['schema must be a schema object'])
|
expect(violationsOf(null)).toEqual(['schema must be a schema object'])
|
||||||
expect(violationsOf([])).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: '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'] }))
|
expect(violationsOf({ type: ['string', 'null'] }))
|
||||||
.toEqual(['schema.type must be a single type string (type arrays are not supported)'])
|
.toEqual(['schema.type must be a single type string (type arrays are not supported)'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects unsupported constraint keywords loudly (never accepted-then-ignored)', () => {
|
it('enforces oneOf vocabulary and its minimum branch count', () => {
|
||||||
for (const keyword of ['oneOf', 'anyOf', 'allOf', 'not', 'pattern', 'minimum', 'maxLength', '$ref']) {
|
expect(violationsOf({ oneOf: [] })).toEqual(['schema.oneOf must be an array of at least two schemas'])
|
||||||
const bad = violationsOf({ type: 'object', [keyword]: [] })
|
expect(violationsOf({ oneOf: [{}] })).toEqual(['schema.oneOf must be an array of at least two schemas'])
|
||||||
expect(bad.some(v => v.includes(`schema.${keyword} is not a supported keyword`))).toBe(true)
|
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')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reports EVERY violation, not just the first', () => {
|
it('rejects unknown and misplaced keywords without accepted-then-ignored behavior', () => {
|
||||||
const bad = violationsOf({
|
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',
|
type: 'object',
|
||||||
pattern: 'x',
|
pattern: 'x',
|
||||||
properties: { a: { type: 'weird' }, b: { type: 'string', minimum: 1 } },
|
properties: { a: { type: 'weird' }, b: { type: 'string', minimum: 1 } },
|
||||||
})
|
})).toHaveLength(3)
|
||||||
expect(bad.length).toBe(3)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects keywords on the wrong type (items on object, properties on string, enum on object)', () => {
|
it('validates object properties, required names, and openness', () => {
|
||||||
expect(violationsOf({ type: 'object', items: { type: 'string' } }))
|
expect(violationsOf({ type: 'object', properties: [] }))
|
||||||
.toEqual(['schema.items is not supported on type "object"'])
|
.toEqual(['schema.properties must be an object of schemas'])
|
||||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', properties: {} } } }))
|
expect(violationsOf({ type: 'object', properties: { a: 'x' } }))
|
||||||
.toEqual(['schema.properties.a.properties is not supported on type "string"'])
|
.toEqual(['schema.properties.a must be a schema object'])
|
||||||
expect(violationsOf({ type: 'object', enum: [1] }))
|
expect(violationsOf({ type: 'object', required: 'a' }))
|
||||||
.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' }))
|
|
||||||
.toEqual(['schema.required must be an array of strings'])
|
.toEqual(['schema.required must be an array of strings'])
|
||||||
expect(violationsOf({ type: 'object', required: [1] }))
|
expect(violationsOf({ type: 'object', required: [1] }))
|
||||||
.toEqual(['schema.required must be an array of strings'])
|
.toEqual(['schema.required must be an array of strings'])
|
||||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'string' } }, required: ['b'] }))
|
expect(violationsOf({ type: 'object', properties: {}, required: ['missing'] }))
|
||||||
.toEqual(['schema.required names "b" which is not in properties'])
|
.toEqual(['schema.required names "missing" which is not in properties'])
|
||||||
expect(violationsOf({ type: 'object', required: ['a'] }))
|
expect(violationsOf({ type: 'object', additionalProperties: 'yes' }))
|
||||||
.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: {} }))
|
|
||||||
.toEqual(['schema.additionalProperties must be a boolean'])
|
.toEqual(['schema.additionalProperties must be a boolean'])
|
||||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: [] } } }))
|
expect(violationsOf({ type: 'object', properties: undefined }))
|
||||||
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
|
.toEqual(['schema.properties must be an object of schemas'])
|
||||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: [{}] } } }))
|
expect(violationsOf({ type: 'object', properties: undefined, required: ['missing'] }))
|
||||||
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
|
.toEqual([
|
||||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: 'x' } } }))
|
'schema.properties must be an object of schemas',
|
||||||
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
|
'schema.required names "missing" which is not in properties',
|
||||||
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'])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects non-string description/title and non-JSON annotation payloads', () => {
|
it('requires type-correct scalar enum and const values', () => {
|
||||||
expect(violationsOf({ type: 'object', description: 7 }))
|
for (const schema of [
|
||||||
.toEqual(['schema.description must be a string'])
|
{ type: 'string', enum: ['a'], const: 'a' },
|
||||||
expect(violationsOf({ type: 'object', title: 7 }))
|
{ type: 'number', enum: [1.5], const: 1.5 },
|
||||||
.toEqual(['schema.title must be a string'])
|
{ type: 'integer', enum: [1], const: 1 },
|
||||||
expect(violationsOf({ type: 'object', default: () => 1 }))
|
{ type: 'boolean', enum: [true], const: true },
|
||||||
.toEqual(['schema.default annotation must be JSON data'])
|
{ type: 'null', enum: [null], const: null },
|
||||||
expect(violationsOf({ type: 'object', examples: [undefined] }))
|
]) {
|
||||||
.toEqual(['schema.examples annotation must be JSON data'])
|
expect(() => { assertSupportedJsonSchema(schema) }, JSON.stringify(schema)).not.toThrow()
|
||||||
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.
|
expect(violationsOf({ type: 'string', enum: [] }))
|
||||||
const cyclicAnnotation: Record<string, unknown> = {}
|
.toEqual(['schema.enum must be a non-empty array of string values'])
|
||||||
cyclicAnnotation.self = cyclicAnnotation
|
expect(violationsOf({ type: 'number', enum: ['1'] }))
|
||||||
expect(violationsOf({ type: 'object', default: cyclicAnnotation }))
|
.toEqual(['schema.enum must be a non-empty array of number values'])
|
||||||
.toEqual(['schema.default annotation must be JSON data'])
|
expect(violationsOf({ type: 'integer', enum: [1.5] }))
|
||||||
// Object/array annotations that ARE JSON data pass.
|
.toEqual(['schema.enum must be a non-empty array of integer values'])
|
||||||
asserted({ type: 'object', default: { a: [1, 'x', null, true] } })
|
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'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects a circular schema instead of recursing forever', () => {
|
it('validates annotation types and lossless JSON payloads', () => {
|
||||||
const node: Record<string, unknown> = { type: 'object' }
|
expect(violationsOf({ description: 1 })).toEqual(['schema.description must be a string'])
|
||||||
node.properties = { self: node }
|
expect(violationsOf({ title: 1 })).toEqual(['schema.title must be a string'])
|
||||||
expect(violationsOf(node)).toEqual(['schema.properties.self is circular'])
|
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'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('accepts the same subschema object reused in two SIBLING positions (a DAG, not a cycle)', () => {
|
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' }
|
const leaf = { type: 'string' }
|
||||||
asserted({ type: 'object', properties: { a: leaf, b: leaf } })
|
expect(() => { assertSupportedJsonSchema({ type: 'object', properties: { a: leaf, b: leaf } }) }).not.toThrow()
|
||||||
})
|
|
||||||
|
|
||||||
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.
|
|
||||||
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() }))
|
expect(violationsOf({ type: 'object', properties: new Map() }))
|
||||||
.toEqual(['schema.properties must be an object of schemas'])
|
.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) } }))
|
expect(violationsOf({ type: 'object', properties: { at: new Date(0) } }))
|
||||||
.toEqual(['schema.properties.at must be a schema object'])
|
.toEqual(['schema.properties.at must be a schema object'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects exotic annotation payloads that would serialize lossily', () => {
|
it('uses own-property semantics for required declarations', () => {
|
||||||
expect(violationsOf({ type: 'object', default: new Date(0) }))
|
expect(violationsOf({ type: 'object', properties: {}, required: ['toString'] }))
|
||||||
.toEqual(['schema.default annotation must be JSON data'])
|
.toEqual(['schema.required names "toString" which is not in properties'])
|
||||||
expect(violationsOf({ type: 'object', examples: [new Map()] }))
|
|
||||||
.toEqual(['schema.examples annotation must be JSON data'])
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('validateStructuredValue', () => {
|
describe('validateJsonSchemaValue', () => {
|
||||||
const schema = asserted({
|
it('validates scalar, array, object, and null roots', () => {
|
||||||
type: 'object',
|
expect(validateJsonSchemaValue(asserted({ type: 'string' }), 'x')).toEqual([])
|
||||||
properties: {
|
expect(validateJsonSchemaValue(asserted({ type: 'number' }), 1.5)).toEqual([])
|
||||||
file: { type: 'string' },
|
expect(validateJsonSchemaValue(asserted({ type: 'integer' }), 2)).toEqual([])
|
||||||
line: { type: 'integer' },
|
expect(validateJsonSchemaValue(asserted({ type: 'boolean' }), true)).toEqual([])
|
||||||
score: { type: 'number' },
|
expect(validateJsonSchemaValue(asserted({ type: 'null' }), null)).toEqual([])
|
||||||
confirmed: { type: 'boolean' },
|
expect(validateJsonSchemaValue(asserted({ type: 'array', items: { type: 'string' } }), ['x'])).toEqual([])
|
||||||
parent: { type: 'null' },
|
expect(validateJsonSchemaValue(asserted({ type: 'object' }), { x: 1 })).toEqual([])
|
||||||
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'],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('accepts a fully valid value (empty violations)', () => {
|
it('rejects wrong scalar types and lossy numbers', () => {
|
||||||
expect(validateStructuredValue(schema, {
|
expect(validateJsonSchemaValue(asserted({ type: 'string' }), 1)).toEqual(['"value" must be a string'])
|
||||||
file: 'a.ts', line: 3, score: 0.5, confirmed: true, parent: null,
|
expect(validateJsonSchemaValue(asserted({ type: 'number' }), '1')).toEqual(['"value" must be a number'])
|
||||||
severity: 'high', kind: 'bug', tags: ['x'], free: [1, { any: true }], nested: { x: 1 },
|
expect(validateJsonSchemaValue(asserted({ type: 'number' }), Number.NaN)).toEqual(['"value" must be a finite JSON number'])
|
||||||
})).toEqual([])
|
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', () => {
|
it('enforces scalar enum and const together', () => {
|
||||||
expect(validateStructuredValue(schema, {})).toEqual(['missing required property "value.file"'])
|
const schema = asserted({ type: 'string', enum: ['a', 'b'], const: 'a' })
|
||||||
expect(validateStructuredValue(schema, 'nope')).toEqual(['"value" must be an object'])
|
expect(validateJsonSchemaValue(schema, 'a')).toEqual([])
|
||||||
expect(validateStructuredValue(schema, [])).toEqual(['"value" must be an object'])
|
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', () => {
|
it('validates object requiredness, nested values, and raw open defaults', () => {
|
||||||
expect(validateStructuredValue(schema, { file: 1 })).toEqual(['"value.file" must be a string'])
|
const open = asserted({
|
||||||
expect(validateStructuredValue(schema, { file: 'a', line: 1.5 })).toEqual(['"value.line" must be an integer'])
|
type: 'object',
|
||||||
expect(validateStructuredValue(schema, { file: 'a', line: 'x' })).toEqual(['"value.line" must be an integer'])
|
properties: {
|
||||||
expect(validateStructuredValue(schema, { file: 'a', score: 'x' })).toEqual(['"value.score" must be a finite number'])
|
file: { type: 'string' },
|
||||||
expect(validateStructuredValue(schema, { file: 'a', score: Number.NaN })).toEqual(['"value.score" must be a finite number'])
|
nested: {
|
||||||
expect(validateStructuredValue(schema, { file: 'a', confirmed: 'yes' })).toEqual(['"value.confirmed" must be a boolean'])
|
type: 'object',
|
||||||
expect(validateStructuredValue(schema, { file: 'a', parent: 0 })).toEqual(['"value.parent" must be null'])
|
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', () => {
|
it('treats present undefined as missing when required, then rejects other lossy objects', () => {
|
||||||
expect(validateStructuredValue(schema, { file: 'a', severity: 'mid' }))
|
const required = asserted({ type: 'object', properties: { x: {} }, required: ['x'] })
|
||||||
.toEqual(['"value.severity" must be one of ["low","high"]'])
|
expect(validateJsonSchemaValue(required, { x: undefined }))
|
||||||
expect(validateStructuredValue(schema, { file: 'a', kind: 'feature' }))
|
.toEqual(['missing required property "value.x"'])
|
||||||
.toEqual(['"value.kind" must be "bug"'])
|
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', () => {
|
it('validates dense arrays per index and rejects lossy arrays', () => {
|
||||||
expect(validateStructuredValue(schema, { file: 'a', tags: 'x' })).toEqual(['"value.tags" must be an array'])
|
const schema = asserted({ type: 'array', items: { type: 'integer' } })
|
||||||
expect(validateStructuredValue(schema, { file: 'a', tags: ['ok', 2] })).toEqual(['"value.tags[1]" must be a string'])
|
expect(validateJsonSchemaValue(schema, [1, 2])).toEqual([])
|
||||||
expect(validateStructuredValue(schema, { file: 'a', free: [{ deep: [1] }, null] })).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('recurses into nested objects: required + additionalProperties: false', () => {
|
it('validates exact-one oneOf semantics, including overlap', () => {
|
||||||
expect(validateStructuredValue(schema, { file: 'a', nested: {} }))
|
const disjoint = asserted({ oneOf: [{ type: 'string' }, { type: 'number' }] })
|
||||||
.toEqual(['missing required property "value.nested.x"'])
|
expect(validateJsonSchemaValue(disjoint, 'x')).toEqual([])
|
||||||
expect(validateStructuredValue(schema, { file: 'a', nested: { x: 1, y: 2 } }))
|
expect(validateJsonSchemaValue(disjoint, null))
|
||||||
.toEqual(['"value.nested.y" is not a declared property (additionalProperties: false)'])
|
.toEqual(['"value" must match exactly one oneOf branch (matched 0)'])
|
||||||
expect(validateStructuredValue(schema, { file: 'a', nested: 3 }))
|
const overlap = asserted({ oneOf: [{ type: 'number' }, { type: 'integer' }] })
|
||||||
.toEqual(['"value.nested" must be an object'])
|
expect(validateJsonSchemaValue(overlap, 1))
|
||||||
|
.toEqual(['"value" must match exactly one oneOf branch (matched 2)'])
|
||||||
|
expect(validateJsonSchemaValue(overlap, 1.5)).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('a required key present-but-undefined counts as missing', () => {
|
it('an unconstrained schema accepts only lossless JSON values', () => {
|
||||||
expect(validateStructuredValue(schema, { file: undefined })).toEqual(['missing required property "value.file"'])
|
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('inherited properties satisfy nothing: required, additionalProperties, and recursion are own-property only', () => {
|
it('uses own properties for requiredness, recursion, and closed-object checks', () => {
|
||||||
// required: ['toString'] must NOT be satisfied by Object.prototype.toString.
|
expect(validateJsonSchemaValue(
|
||||||
expect(validateStructuredValue(
|
|
||||||
asserted({ type: 'object', properties: { toString: { type: 'string' } }, required: ['toString'] }),
|
asserted({ type: 'object', properties: { toString: { type: 'string' } }, required: ['toString'] }),
|
||||||
{},
|
{},
|
||||||
)).toEqual(['missing required property "value.toString"'])
|
)).toEqual(['missing required property "value.toString"'])
|
||||||
// additionalProperties: false must flag an OWN `toString` key even though
|
expect(validateJsonSchemaValue(asserted({ type: 'object', additionalProperties: false }), { toString: 1 }))
|
||||||
// `'toString' in properties` is true via the prototype.
|
.toEqual(['"value.toString" is not a declared property (additionalProperties: false)'])
|
||||||
expect(validateStructuredValue(
|
expect(validateJsonSchemaValue(
|
||||||
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(
|
|
||||||
asserted({ type: 'object', properties: { constructor: { type: 'string' } } }),
|
asserted({ type: 'object', properties: { constructor: { type: 'string' } } }),
|
||||||
{},
|
{},
|
||||||
)).toEqual([])
|
)).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('a non-plain object value is not an object in the JSON sense', () => {
|
it('keeps assertNever as a forged-schema backstop', () => {
|
||||||
expect(validateStructuredValue(asserted({ type: 'object' }), new Date(0)))
|
const forged = { type: 'tuple' } as unknown as JsonSchemaNode
|
||||||
.toEqual(['"value" must be an object'])
|
expect(() => validateJsonSchemaValue(forged, 1)).toThrow(/tuple/)
|
||||||
})
|
|
||||||
|
|
||||||
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/)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,61 +1,91 @@
|
|||||||
/**
|
/**
|
||||||
* Property-based tests for the tool-schema DSL (the property-testing Agent Note), including
|
* 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
|
* pass validateArgs, and targeted corruptions must be rejected. This closes the
|
||||||
* validator/InferArgs drift risk noted in the arg-validation Agent Note.
|
* validator/InferArgs drift risk noted in the arg-validation Agent Note.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import fc from 'fast-check'
|
import fc from 'fast-check'
|
||||||
import { schemaSpecToJsonSchema, validateArgs } from '@deepseek-ai/dsh-tools'
|
import { parameterSchemaSpecToJsonSchema, validateArgs } from '@deepseek-ai/dsh-tools'
|
||||||
import type { SchemaProp, SchemaSpec } 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.
|
// A leaf prop arbitrary (no nesting) with optional required/enum.
|
||||||
function leafPropArb(): fc.Arbitrary<SchemaProp> {
|
function leafPropArb(): fc.Arbitrary<ParameterPropertySpec> {
|
||||||
return fc.oneof(
|
return fc.oneof(
|
||||||
fc.record({ required: fc.boolean() }).map(({ required }): SchemaProp => ({ type: 'string', ...required ? { required: true } : {} })),
|
fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ 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 }): ParameterPropertySpec => ({ 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: '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() })
|
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). */
|
/** 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()
|
if (depth <= 0) return leafPropArb()
|
||||||
return fc.oneof(
|
return fc.oneof(
|
||||||
{ weight: 3, arbitrary: leafPropArb() },
|
{ weight: 3, arbitrary: leafPropArb() },
|
||||||
{
|
{
|
||||||
weight: 1,
|
weight: 1,
|
||||||
arbitrary: fc.record({ properties: specArb(depth - 1), required: fc.boolean() })
|
arbitrary: fc.record({ properties: specArb(depth - 1), required: fc.boolean(), additionalProperties: fc.boolean() })
|
||||||
.map(({ properties, required }): SchemaProp => ({ type: 'object', properties, ...required ? { required: true } : {} })),
|
.map(({ properties, required, additionalProperties }): ParameterPropertySpec => ({
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties,
|
||||||
|
properties,
|
||||||
|
...required ? { required: true } : {},
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
weight: 1,
|
weight: 1,
|
||||||
arbitrary: fc.record({ items: propArb(depth - 1), required: fc.boolean() })
|
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 })
|
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). */
|
/** 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) {
|
switch (prop.type) {
|
||||||
case 'string': return prop.enum ? fc.constantFrom(...prop.enum) : fc.string()
|
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 'boolean': return fc.boolean()
|
||||||
|
case 'null': return fc.constant(null)
|
||||||
case 'object': return prop.properties ? validArgsForSpec(prop.properties) : fc.constant({})
|
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 'array': return prop.items ? fc.array(valueForProp(prop.items), { maxLength: 3 }) : fc.constant([])
|
||||||
|
case 'json': return fc.jsonValue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generate args satisfying every required key of a spec (optionals included randomly). */
|
/** 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)
|
const entries = Object.entries(spec)
|
||||||
return fc.tuple(...entries.map(([key, prop]) =>
|
return fc.tuple(...entries.map(([key, prop]) =>
|
||||||
fc.tuple(
|
fc.tuple(
|
||||||
@@ -76,29 +106,29 @@ function validArgsForSpec(spec: SchemaSpec): fc.Arbitrary<Record<string, unknown
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Collect the `required: true` keys at the top level of a spec. */
|
/** 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)
|
return Object.entries(spec).filter(([, p]) => p.required === true).map(([k]) => k)
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('schema DSL properties', () => {
|
describe('schema DSL properties', () => {
|
||||||
it('JSON Schema `required` equals the required:true keys at every level', () => {
|
it('JSON Schema `required` equals the required:true keys at every level', () => {
|
||||||
fc.assert(fc.property(specArb(2), (spec) => {
|
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)))
|
expect(new Set(json.required ?? [])).toEqual(new Set(requiredKeys(s)))
|
||||||
for (const [key, prop] of Object.entries(s)) {
|
for (const [key, prop] of Object.entries(s)) {
|
||||||
const propJson = json.properties[key] as Record<string, unknown>
|
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(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', () => {
|
it('conversion is total (never throws) for any spec', () => {
|
||||||
fc.assert(fc.property(specArb(3), (spec) => {
|
fc.assert(fc.property(specArb(3), (spec) => {
|
||||||
expect(() => schemaSpecToJsonSchema(spec)).not.toThrow()
|
expect(() => parameterSchemaSpecToJsonSchema(spec)).not.toThrow()
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
138
packages/core/tools/tests/schema.spec.ts
Normal file
138
packages/core/tools/tests/schema.spec.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
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: '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)
|
||||||
|
})
|
||||||
|
|
||||||
|
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('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('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 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,
|
||||||
|
}
|
||||||
|
expect(Object.keys(invalidObjects)).toHaveLength(4)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,8 +5,8 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
|||||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||||
import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
|
import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
|
||||||
import ToolRegistry, {
|
import ToolRegistry, {
|
||||||
defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
|
defineTool, JsonSchemaError, parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
|
||||||
type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
|
type InferArgs, type JsonValue, type ParameterSchemaSpec, type PreToolDecision, type PostToolDecision,
|
||||||
type ToolExecution, type ToolExecutionResult,
|
type ToolExecution, type ToolExecutionResult,
|
||||||
} from '@deepseek-ai/dsh-tools'
|
} from '@deepseek-ai/dsh-tools'
|
||||||
|
|
||||||
@@ -775,13 +775,13 @@ describe('ToolRegistry', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('defineTool / schema DSL', () => {
|
describe('defineTool / schema DSL', () => {
|
||||||
it('converts SchemaSpec to standard JSON Schema with required array', () => {
|
it('converts ParameterSchemaSpec to standard JSON Schema with required array', () => {
|
||||||
const spec = {
|
const spec = {
|
||||||
path: { type: 'string', required: true, description: 'Absolute path' },
|
path: { type: 'string', required: true, description: 'Absolute path' },
|
||||||
offset: { type: 'number' },
|
offset: { type: 'number' },
|
||||||
limit: { type: 'number', description: 'Max lines' },
|
limit: { type: 'number', description: 'Max lines' },
|
||||||
} satisfies SchemaSpec
|
} satisfies ParameterSchemaSpec
|
||||||
const jsonSchema = schemaSpecToJsonSchema(spec)
|
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
|
||||||
expect(jsonSchema).toEqual({
|
expect(jsonSchema).toEqual({
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -794,7 +794,7 @@ describe('defineTool / schema DSL', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('handles empty spec (no properties, no required)', () => {
|
it('handles empty spec (no properties, no required)', () => {
|
||||||
expect(schemaSpecToJsonSchema({})).toEqual({
|
expect(parameterSchemaSpecToJsonSchema({})).toEqual({
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {},
|
properties: {},
|
||||||
})
|
})
|
||||||
@@ -804,19 +804,21 @@ describe('defineTool / schema DSL', () => {
|
|||||||
const spec = {
|
const spec = {
|
||||||
config: {
|
config: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: true,
|
||||||
required: true,
|
required: true,
|
||||||
properties: {
|
properties: {
|
||||||
host: { type: 'string', required: true },
|
host: { type: 'string', required: true },
|
||||||
port: { type: 'number' },
|
port: { type: 'number' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} satisfies SchemaSpec
|
} satisfies ParameterSchemaSpec
|
||||||
const jsonSchema = schemaSpecToJsonSchema(spec)
|
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
|
||||||
expect(jsonSchema).toEqual({
|
expect(jsonSchema).toEqual({
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
config: {
|
config: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: true,
|
||||||
properties: {
|
properties: {
|
||||||
host: { type: 'string' },
|
host: { type: 'string' },
|
||||||
port: { type: 'number' },
|
port: { type: 'number' },
|
||||||
@@ -958,8 +960,8 @@ describe('schema DSL edge cases', () => {
|
|||||||
it('emits enum values in JSON Schema property', () => {
|
it('emits enum values in JSON Schema property', () => {
|
||||||
const spec = {
|
const spec = {
|
||||||
color: { type: 'string', enum: ['red', 'green', 'blue'], description: 'Color choice' },
|
color: { type: 'string', enum: ['red', 'green', 'blue'], description: 'Color choice' },
|
||||||
} satisfies SchemaSpec
|
} satisfies ParameterSchemaSpec
|
||||||
const jsonSchema = schemaSpecToJsonSchema(spec)
|
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
|
||||||
expect(jsonSchema.properties['color']).toMatchObject({
|
expect(jsonSchema.properties['color']).toMatchObject({
|
||||||
type: 'string',
|
type: 'string',
|
||||||
enum: ['red', 'green', 'blue'],
|
enum: ['red', 'green', 'blue'],
|
||||||
@@ -970,8 +972,8 @@ describe('schema DSL edge cases', () => {
|
|||||||
it('emits default value in JSON Schema property', () => {
|
it('emits default value in JSON Schema property', () => {
|
||||||
const spec = {
|
const spec = {
|
||||||
limit: { type: 'number', default: 25 },
|
limit: { type: 'number', default: 25 },
|
||||||
} satisfies SchemaSpec
|
} satisfies ParameterSchemaSpec
|
||||||
const jsonSchema = schemaSpecToJsonSchema(spec)
|
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
|
||||||
expect(jsonSchema.properties['limit']).toMatchObject({
|
expect(jsonSchema.properties['limit']).toMatchObject({
|
||||||
type: 'number',
|
type: 'number',
|
||||||
default: 25,
|
default: 25,
|
||||||
@@ -981,8 +983,8 @@ describe('schema DSL edge cases', () => {
|
|||||||
it('handles array items without nested properties (plain type array)', () => {
|
it('handles array items without nested properties (plain type array)', () => {
|
||||||
const spec = {
|
const spec = {
|
||||||
tags: { type: 'array', items: { type: 'string' } },
|
tags: { type: 'array', items: { type: 'string' } },
|
||||||
} satisfies SchemaSpec
|
} satisfies ParameterSchemaSpec
|
||||||
const jsonSchema = schemaSpecToJsonSchema(spec)
|
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
|
||||||
expect(jsonSchema.properties['tags']).toEqual({
|
expect(jsonSchema.properties['tags']).toEqual({
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: { type: 'string' },
|
items: { type: 'string' },
|
||||||
@@ -992,8 +994,8 @@ describe('schema DSL edge cases', () => {
|
|||||||
it('handles enum and default together in one property', () => {
|
it('handles enum and default together in one property', () => {
|
||||||
const spec = {
|
const spec = {
|
||||||
level: { type: 'string', enum: ['low', 'high'], default: 'low' },
|
level: { type: 'string', enum: ['low', 'high'], default: 'low' },
|
||||||
} satisfies SchemaSpec
|
} satisfies ParameterSchemaSpec
|
||||||
const jsonSchema = schemaSpecToJsonSchema(spec)
|
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
|
||||||
expect(jsonSchema.properties['level']).toMatchObject({
|
expect(jsonSchema.properties['level']).toMatchObject({
|
||||||
type: 'string',
|
type: 'string',
|
||||||
enum: ['low', 'high'],
|
enum: ['low', 'high'],
|
||||||
@@ -1004,8 +1006,8 @@ describe('schema DSL edge cases', () => {
|
|||||||
it('omits description, enum, default keys when not specified', () => {
|
it('omits description, enum, default keys when not specified', () => {
|
||||||
const spec = {
|
const spec = {
|
||||||
bare: { type: 'string' },
|
bare: { type: 'string' },
|
||||||
} satisfies SchemaSpec
|
} satisfies ParameterSchemaSpec
|
||||||
const jsonSchema = schemaSpecToJsonSchema(spec)
|
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
|
||||||
const prop = jsonSchema.properties['bare'] as Record<string, unknown>
|
const prop = jsonSchema.properties['bare'] as Record<string, unknown>
|
||||||
expect(prop).toEqual({ type: 'string' })
|
expect(prop).toEqual({ type: 'string' })
|
||||||
expect('description' in prop).toBe(false)
|
expect('description' in prop).toBe(false)
|
||||||
@@ -1016,8 +1018,8 @@ describe('schema DSL edge cases', () => {
|
|||||||
it('handles array with no items (items omitted)', () => {
|
it('handles array with no items (items omitted)', () => {
|
||||||
const spec = {
|
const spec = {
|
||||||
raw: { type: 'array' },
|
raw: { type: 'array' },
|
||||||
} satisfies SchemaSpec
|
} satisfies ParameterSchemaSpec
|
||||||
const jsonSchema = schemaSpecToJsonSchema(spec)
|
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
|
||||||
expect(jsonSchema.properties['raw']).toEqual({
|
expect(jsonSchema.properties['raw']).toEqual({
|
||||||
type: 'array',
|
type: 'array',
|
||||||
})
|
})
|
||||||
@@ -1027,13 +1029,14 @@ describe('schema DSL edge cases', () => {
|
|||||||
const spec = {
|
const spec = {
|
||||||
config: {
|
config: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: true,
|
||||||
properties: {
|
properties: {
|
||||||
host: { type: 'string' },
|
host: { type: 'string' },
|
||||||
port: { type: 'number' },
|
port: { type: 'number' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} satisfies SchemaSpec
|
} satisfies ParameterSchemaSpec
|
||||||
const jsonSchema = schemaSpecToJsonSchema(spec)
|
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
|
||||||
expect(jsonSchema.properties['config']).toMatchObject({
|
expect(jsonSchema.properties['config']).toMatchObject({
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -1064,6 +1067,7 @@ describe('schema DSL optional and nested contracts', () => {
|
|||||||
type: 'array'
|
type: 'array'
|
||||||
items: {
|
items: {
|
||||||
type: 'object'
|
type: 'object'
|
||||||
|
additionalProperties: true
|
||||||
properties: {
|
properties: {
|
||||||
host: { type: 'string'; required: true }
|
host: { type: 'string'; required: true }
|
||||||
port: { type: 'number' }
|
port: { type: 'number' }
|
||||||
@@ -1073,7 +1077,7 @@ describe('schema DSL optional and nested contracts', () => {
|
|||||||
}>
|
}>
|
||||||
expectTypeOf<Args>().toEqualTypeOf<{
|
expectTypeOf<Args>().toEqualTypeOf<{
|
||||||
names: string[]
|
names: string[]
|
||||||
servers?: { host: string; port?: number }[]
|
servers?: ({ host: string; port?: number } & Record<string, JsonValue>)[]
|
||||||
}>()
|
}>()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1083,20 +1087,22 @@ describe('schema DSL optional and nested contracts', () => {
|
|||||||
type: 'array',
|
type: 'array',
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: true,
|
||||||
properties: {
|
properties: {
|
||||||
host: { type: 'string', required: true },
|
host: { type: 'string', required: true },
|
||||||
port: { type: 'number' },
|
port: { type: 'number' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} satisfies SchemaSpec
|
} satisfies ParameterSchemaSpec
|
||||||
expect(schemaSpecToJsonSchema(spec)).toEqual({
|
expect(parameterSchemaSpecToJsonSchema(spec)).toEqual({
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
servers: {
|
servers: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: true,
|
||||||
properties: {
|
properties: {
|
||||||
host: { type: 'string' },
|
host: { type: 'string' },
|
||||||
port: { type: 'number' },
|
port: { type: 'number' },
|
||||||
@@ -1178,7 +1184,7 @@ describe('validateArgs (the runtime-validation Agent Note, part 1)', () => {
|
|||||||
const spec = {
|
const spec = {
|
||||||
path: { type: 'string', required: true },
|
path: { type: 'string', required: true },
|
||||||
limit: { type: 'number' },
|
limit: { type: 'number' },
|
||||||
} satisfies SchemaSpec
|
} satisfies ParameterSchemaSpec
|
||||||
expect(validateArgs(spec, { path: '/tmp' })).toEqual([])
|
expect(validateArgs(spec, { path: '/tmp' })).toEqual([])
|
||||||
expect(validateArgs(spec, { path: '/tmp', limit: 5 })).toEqual([])
|
expect(validateArgs(spec, { path: '/tmp', limit: 5 })).toEqual([])
|
||||||
// never throws regardless of shape
|
// never throws regardless of shape
|
||||||
@@ -1188,18 +1194,18 @@ describe('validateArgs (the runtime-validation Agent Note, part 1)', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('flags a missing required key and a required key present as undefined', () => {
|
it('flags a missing required key and a required key present as undefined', () => {
|
||||||
const spec = { path: { type: 'string', required: true } } satisfies SchemaSpec
|
const spec = { path: { type: 'string', required: true } } satisfies ParameterSchemaSpec
|
||||||
expect(validateArgs(spec, {})).toEqual(['missing required property "path"'])
|
expect(validateArgs(spec, {})).toEqual(['missing required property "path"'])
|
||||||
expect(validateArgs(spec, { path: undefined })).toEqual(['missing required property "path"'])
|
expect(validateArgs(spec, { path: undefined })).toEqual(['missing required property "path"'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('allows extra keys (no additionalProperties:false) and omitted optionals', () => {
|
it('allows extra keys (no additionalProperties:false) and omitted optionals', () => {
|
||||||
const spec = { path: { type: 'string', required: true } } satisfies SchemaSpec
|
const spec = { path: { type: 'string', required: true } } satisfies ParameterSchemaSpec
|
||||||
expect(validateArgs(spec, { path: '/tmp', extra: 1 })).toEqual([])
|
expect(validateArgs(spec, { path: '/tmp', extra: 1 })).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not apply defaults (validation only)', () => {
|
it('does not apply defaults (validation only)', () => {
|
||||||
const spec = { limit: { type: 'number', default: 25 } } satisfies SchemaSpec
|
const spec = { limit: { type: 'number', default: 25 } } satisfies ParameterSchemaSpec
|
||||||
// absent optional is valid, and validation does not synthesize the default
|
// absent optional is valid, and validation does not synthesize the default
|
||||||
expect(validateArgs(spec, {})).toEqual([])
|
expect(validateArgs(spec, {})).toEqual([])
|
||||||
})
|
})
|
||||||
@@ -1209,39 +1215,41 @@ describe('validateArgs (the runtime-validation Agent Note, part 1)', () => {
|
|||||||
s: { type: 'string' },
|
s: { type: 'string' },
|
||||||
n: { type: 'number' },
|
n: { type: 'number' },
|
||||||
b: { type: 'boolean' },
|
b: { type: 'boolean' },
|
||||||
} satisfies SchemaSpec
|
} satisfies ParameterSchemaSpec
|
||||||
expect(validateArgs(spec, { s: 1 })).toEqual(['"s" must be a string'])
|
expect(validateArgs(spec, { s: 1 })).toEqual(['"s" must be a string'])
|
||||||
expect(validateArgs(spec, { n: 'x' })).toEqual(['"n" must be a number'])
|
expect(validateArgs(spec, { n: 'x' })).toEqual(['"n" must be a number'])
|
||||||
expect(validateArgs(spec, { b: 'x' })).toEqual(['"b" must be a boolean'])
|
expect(validateArgs(spec, { b: 'x' })).toEqual(['"b" must be a boolean'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('checks enum membership', () => {
|
it('checks enum membership', () => {
|
||||||
const spec = { color: { type: 'string', enum: ['red', 'green'] } } satisfies SchemaSpec
|
const spec = { color: { type: 'string', enum: ['red', 'green'] } } satisfies ParameterSchemaSpec
|
||||||
expect(validateArgs(spec, { color: 'red' })).toEqual([])
|
expect(validateArgs(spec, { color: 'red' })).toEqual([])
|
||||||
expect(validateArgs(spec, { color: 'blue' })).toEqual(['"color" must be one of ["red","green"]'])
|
expect(validateArgs(spec, { color: 'blue' })).toEqual(['"color" must be one of ["red","green"]'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('checks enum uniformly with the converter (enum on a non-string prop)', () => {
|
it('enforces type-correct scalar enum declarations', () => {
|
||||||
// The converter emits `enum` regardless of type; the validator must agree.
|
const spec = { n: { type: 'number', enum: [1, 2] } } satisfies ParameterSchemaSpec
|
||||||
// `enum` is string[], so a number value can never be a member.
|
expect(validateArgs(spec, { n: 1 })).toEqual([])
|
||||||
const spec = { n: { type: 'number', enum: ['1', '2'] } } as unknown as SchemaSpec
|
expect(validateArgs(spec, { n: 3 })).toEqual(['"n" must be one of [1,2]'])
|
||||||
expect(validateArgs(spec, { n: 1 })).toEqual(['"n" must be one of ["1","2"]'])
|
const invalid = { n: { type: 'number', enum: ['1', '2'] } } as unknown as ParameterSchemaSpec
|
||||||
|
expect(() => validateArgs(invalid, { n: 1 })).toThrow(JsonSchemaError)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects an unknown SchemaType at runtime (assertNever guard)', () => {
|
it('rejects an unknown schema type at the author boundary', () => {
|
||||||
const spec = { x: { type: 'weird' } } as unknown as SchemaSpec
|
const spec = { x: { type: 'weird' } } as unknown as ParameterSchemaSpec
|
||||||
expect(() => validateArgs(spec, { x: 1 })).toThrow(/unreachable variant.*validateArgs/)
|
expect(() => validateArgs(spec, { x: 1 })).toThrow(JsonSchemaError)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('recurses into nested objects (and an object without properties only type-checks)', () => {
|
it('recurses into nested objects (and an object without properties only type-checks)', () => {
|
||||||
const spec = {
|
const spec = {
|
||||||
config: {
|
config: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: true,
|
||||||
required: true,
|
required: true,
|
||||||
properties: { host: { type: 'string', required: true }, port: { type: 'number' } },
|
properties: { host: { type: 'string', required: true }, port: { type: 'number' } },
|
||||||
},
|
},
|
||||||
bag: { type: 'object' },
|
bag: { type: 'object', additionalProperties: true },
|
||||||
} satisfies SchemaSpec
|
} satisfies ParameterSchemaSpec
|
||||||
expect(validateArgs(spec, { config: { host: 'h' }, bag: { anything: true } })).toEqual([])
|
expect(validateArgs(spec, { config: { host: 'h' }, bag: { anything: true } })).toEqual([])
|
||||||
expect(validateArgs(spec, { config: { port: 9 }, bag: 5 })).toEqual([
|
expect(validateArgs(spec, { config: { port: 9 }, bag: 5 })).toEqual([
|
||||||
'missing required property "config.host"',
|
'missing required property "config.host"',
|
||||||
@@ -1253,7 +1261,7 @@ describe('validateArgs (the runtime-validation Agent Note, part 1)', () => {
|
|||||||
const spec = {
|
const spec = {
|
||||||
tags: { type: 'array', items: { type: 'string' } },
|
tags: { type: 'array', items: { type: 'string' } },
|
||||||
raw: { type: 'array' },
|
raw: { type: 'array' },
|
||||||
} satisfies SchemaSpec
|
} satisfies ParameterSchemaSpec
|
||||||
expect(validateArgs(spec, { tags: ['a', 'b'], raw: [1, {}, 'x'] })).toEqual([])
|
expect(validateArgs(spec, { tags: ['a', 'b'], raw: [1, {}, 'x'] })).toEqual([])
|
||||||
expect(validateArgs(spec, { tags: ['a', 2] })).toEqual(['"tags[1]" must be a string'])
|
expect(validateArgs(spec, { tags: ['a', 2] })).toEqual(['"tags[1]" must be a string'])
|
||||||
// a non-array value for an array-typed prop
|
// a non-array value for an array-typed prop
|
||||||
@@ -1264,9 +1272,9 @@ describe('validateArgs (the runtime-validation Agent Note, part 1)', () => {
|
|||||||
const spec = {
|
const spec = {
|
||||||
servers: {
|
servers: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: { type: 'object', properties: { host: { type: 'string', required: true } } },
|
items: { type: 'object', additionalProperties: true, properties: { host: { type: 'string', required: true } } },
|
||||||
},
|
},
|
||||||
} satisfies SchemaSpec
|
} satisfies ParameterSchemaSpec
|
||||||
expect(validateArgs(spec, { servers: [{ host: 'a' }, {}] })).toEqual([
|
expect(validateArgs(spec, { servers: [{ host: 'a' }, {}] })).toEqual([
|
||||||
'missing required property "servers[1].host"',
|
'missing required property "servers[1].host"',
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -1,20 +1,37 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { jsonSchemaToTs, renderToolsSdk } from '@deepseek-ai/dsh-tools/src/ts-types.ts'
|
import { jsonSchemaToTs, renderToolsSdk } from '@deepseek-ai/dsh-tools/src/ts-types.ts'
|
||||||
import { schemaSpecToJsonSchema } from '@deepseek-ai/dsh-tools'
|
import { parameterSchemaSpecToJsonSchema } from '@deepseek-ai/dsh-tools'
|
||||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
|
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||||
|
|
||||||
describe('jsonSchemaToTs', () => {
|
describe('jsonSchemaToTs', () => {
|
||||||
it('maps the defineTool DSL subset', () => {
|
it('maps every unified schema construct', () => {
|
||||||
const cases: [unknown, string][] = [
|
const cases: [unknown, string][] = [
|
||||||
[{ type: 'string' }, 'string'],
|
[{ type: 'string' }, 'string'],
|
||||||
[{ type: 'number' }, 'number'],
|
[{ type: 'number' }, 'number'],
|
||||||
|
[{ type: 'integer' }, 'number'],
|
||||||
[{ type: 'boolean' }, 'boolean'],
|
[{ type: 'boolean' }, 'boolean'],
|
||||||
|
[{ type: 'null' }, 'null'],
|
||||||
[{ type: 'string', enum: ['a', 'b'] }, '"a" | "b"'],
|
[{ 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: 'number' } }, 'number[]'],
|
||||||
[{ type: 'array', items: { type: 'string', enum: ['x', 'y'] } }, '("x" | "y")[]'],
|
[{ type: 'array', items: { type: 'string', enum: ['x', 'y'] } }, '("x" | "y")[]'],
|
||||||
[{ type: 'array' }, 'unknown[]'],
|
[{ type: 'array' }, 'JsonValue[]'],
|
||||||
[{ type: 'object' }, 'Record<string, unknown>'],
|
[{ type: 'object' }, 'Record<string, JsonValue>'],
|
||||||
[{ type: 'object', properties: {} }, 'Record<string, unknown>'],
|
[{ 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) {
|
for (const [schema, expected] of cases) {
|
||||||
expect(jsonSchemaToTs(schema), JSON.stringify(schema)).toBe(expected)
|
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', () => {
|
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' },
|
path: { type: 'string', required: true, description: 'Absolute file path' },
|
||||||
limit: { type: 'number' },
|
limit: { type: 'number' },
|
||||||
opts: {
|
opts: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: true,
|
||||||
properties: { deep: { type: 'boolean', required: true } },
|
properties: { deep: { type: 'boolean', required: true } },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -37,8 +55,8 @@ describe('jsonSchemaToTs', () => {
|
|||||||
' limit?: number;',
|
' limit?: number;',
|
||||||
' opts?: {',
|
' opts?: {',
|
||||||
' deep: boolean;',
|
' deep: boolean;',
|
||||||
' };',
|
' } & Record<string, JsonValue>;',
|
||||||
'}',
|
'} & Record<string, JsonValue>',
|
||||||
].join('\n'))
|
].join('\n'))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -48,9 +66,6 @@ describe('jsonSchemaToTs', () => {
|
|||||||
null,
|
null,
|
||||||
42,
|
42,
|
||||||
'string-schema',
|
'string-schema',
|
||||||
{},
|
|
||||||
{ type: 'integer' },
|
|
||||||
{ type: 'null' },
|
|
||||||
{ oneOf: [{ type: 'string' }] },
|
{ oneOf: [{ type: 'string' }] },
|
||||||
{ $ref: '#/defs/x' },
|
{ $ref: '#/defs/x' },
|
||||||
{ type: 'object', properties: 7 },
|
{ type: 'object', properties: 7 },
|
||||||
@@ -61,18 +76,13 @@ describe('jsonSchemaToTs', () => {
|
|||||||
for (const schema of cases) {
|
for (const schema of cases) {
|
||||||
expect(() => jsonSchemaToTs(schema), JSON.stringify(schema)).not.toThrow()
|
expect(() => jsonSchemaToTs(schema), JSON.stringify(schema)).not.toThrow()
|
||||||
}
|
}
|
||||||
expect(jsonSchemaToTs({ type: 'integer' })).toBe('unknown')
|
|
||||||
expect(jsonSchemaToTs({ oneOf: [] })).toBe('unknown')
|
expect(jsonSchemaToTs({ oneOf: [] })).toBe('unknown')
|
||||||
expect(jsonSchemaToTs({ type: 'object', properties: 7 })).toBe('Record<string, unknown>')
|
expect(jsonSchemaToTs({ type: 'object', properties: 7 })).toBe('unknown')
|
||||||
expect(jsonSchemaToTs({ type: 'object', properties: { bad: { $ref: 'x' } }, required: ['bad'] })).toContain('bad: unknown;')
|
expect(jsonSchemaToTs({ type: 'object', properties: { bad: { $ref: 'x' } }, required: ['bad'] })).toBe('unknown')
|
||||||
// A non-string-only enum degrades to plain string; an empty one too.
|
expect(jsonSchemaToTs({ type: 'string', enum: [1, 2] })).toBe('unknown')
|
||||||
expect(jsonSchemaToTs({ type: 'string', enum: [1, 2] })).toBe('string')
|
expect(jsonSchemaToTs({ type: 'string', enum: [] })).toBe('unknown')
|
||||||
expect(jsonSchemaToTs({ type: 'string', enum: [] })).toBe('string')
|
expect(jsonSchemaToTs({ type: 'object', properties: { a: { type: 'string' } }, required: [7] })).toBe('unknown')
|
||||||
// A hostile required list only accepts string members.
|
expect(jsonSchemaToTs({ type: 'object', properties: { weird: 42 } })).toBe('unknown')
|
||||||
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;')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('escapes a comment-closer inside a description so the generated JSDoc cannot end early', () => {
|
it('escapes a comment-closer inside a description so the generated JSDoc cannot end early', () => {
|
||||||
@@ -89,17 +99,18 @@ describe('renderToolsSdk', () => {
|
|||||||
const bash: ToolSchema = {
|
const bash: ToolSchema = {
|
||||||
name: 'bash',
|
name: 'bash',
|
||||||
description: 'Run a shell command.',
|
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>,
|
||||||
}
|
}
|
||||||
const exotic: ToolSchema = {
|
const exotic: ToolSchema = {
|
||||||
name: 'my-mcp.tool',
|
name: 'my-mcp.tool',
|
||||||
description: 'Exotic name.',
|
description: 'Exotic name.',
|
||||||
parameters: schemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
|
parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
|
||||||
}
|
}
|
||||||
|
|
||||||
it('declares every tool in lexicographic order with quoted keys for exotic names', () => {
|
it('declares every tool in lexicographic order with quoted keys for exotic names', () => {
|
||||||
const text = renderToolsSdk([exotic, bash])
|
const text = renderToolsSdk([exotic, bash])
|
||||||
expect(text).toContain('declare const tools: {')
|
expect(text).toContain('declare const tools: {')
|
||||||
|
expect(text).toContain('type JsonValue = null | boolean | number | string')
|
||||||
expect(text.indexOf('bash(args:')).toBeGreaterThan(0)
|
expect(text.indexOf('bash(args:')).toBeGreaterThan(0)
|
||||||
expect(text).toContain('"my-mcp.tool"(args:')
|
expect(text).toContain('"my-mcp.tool"(args:')
|
||||||
expect(text.indexOf('bash(args:')).toBeLessThan(text.indexOf('"my-mcp.tool"(args:'))
|
expect(text.indexOf('bash(args:')).toBeLessThan(text.indexOf('"my-mcp.tool"(args:'))
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type { Context } from 'cordis'
|
|||||||
import type { ContinuationStop } from '@deepseek-ai/dsh-agent'
|
import type { ContinuationStop } from '@deepseek-ai/dsh-agent'
|
||||||
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
|
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||||
import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
|
import { ToolArgsError, validateJsonSchemaValue, type ObjectJsonSchema } from '@deepseek-ai/dsh-tools'
|
||||||
|
|
||||||
/** The model-facing tool name a structured child must call to finish. */
|
/** The model-facing tool name a structured child must call to finish. */
|
||||||
export const STRUCTURED_OUTPUT_TOOL = 'structured_output'
|
export const STRUCTURED_OUTPUT_TOOL = 'structured_output'
|
||||||
@@ -44,10 +44,10 @@ export interface StructuredAttachment {
|
|||||||
* its creation window. Child disposal removes every registration.
|
* its creation window. Child disposal removes every registration.
|
||||||
* @param childCtx - the child agent's scope context (`setup`'s argument).
|
* @param childCtx - the child agent's scope context (`setup`'s argument).
|
||||||
* @param schema - the trusted, already-asserted schema subset to enforce (see
|
* @param schema - the trusted, already-asserted schema subset to enforce (see
|
||||||
* `assertSupportedOutputSchema` in dsh-tools).
|
* `assertObjectJsonSchema` in dsh-tools).
|
||||||
* @returns the attachment handle (read `captured()` after the child settles).
|
* @returns the attachment handle (read `captured()` after the child settles).
|
||||||
*/
|
*/
|
||||||
export function attachStructuredRuntime(childCtx: Context, schema: StructuredOutputSchema): StructuredAttachment {
|
export function attachStructuredRuntime(childCtx: Context, schema: ObjectJsonSchema): StructuredAttachment {
|
||||||
/**
|
/**
|
||||||
* Validated values staged by the capture tool body, awaiting THEIR OWN
|
* Validated values staged by the capture tool body, awaiting THEIR OWN
|
||||||
* authoritative `tools/result` notification. The execution object's identity
|
* authoritative `tools/result` notification. The execution object's identity
|
||||||
@@ -75,7 +75,7 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut
|
|||||||
childCtx.tools.register({
|
childCtx.tools.register({
|
||||||
...schemaEntry,
|
...schemaEntry,
|
||||||
execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]> {
|
execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]> {
|
||||||
const violations = validateStructuredValue(schema, args)
|
const violations = validateJsonSchemaValue(schema, args)
|
||||||
// ToolArgsError → isError result with INVALID_ARGS: the model retries
|
// ToolArgsError → isError result with INVALID_ARGS: the model retries
|
||||||
// within the same turn, exactly like a schema-validated defineTool call.
|
// within the same turn, exactly like a schema-validated defineTool call.
|
||||||
if (violations.length > 0) throw new ToolArgsError(violations)
|
if (violations.length > 0) throw new ToolArgsError(violations)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
|||||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||||
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||||
import type { Config as ToolConfig, StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
|
import type { Config as ToolConfig, ObjectJsonSchema } from '@deepseek-ai/dsh-tools'
|
||||||
import { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
|
import { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
|
||||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||||
import { startInProcessRun } from '../src/index.ts'
|
import { startInProcessRun } from '../src/index.ts'
|
||||||
@@ -27,7 +27,7 @@ interface SetupOptions {
|
|||||||
codeRun?: (request: CodeRunRequestLike) => Promise<{ logs: never[]; value?: unknown }>
|
codeRun?: (request: CodeRunRequestLike) => Promise<{ logs: never[]; value?: unknown }>
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCHEMA: StructuredOutputSchema = {
|
const SCHEMA: ObjectJsonSchema = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: { answer: { type: 'number' }, note: { type: 'string' } },
|
properties: { answer: { type: 'number' }, note: { type: 'string' } },
|
||||||
required: ['answer'],
|
required: ['answer'],
|
||||||
@@ -320,17 +320,17 @@ describe('in-process structured output', () => {
|
|||||||
it('rejects a schema outside the subset loud, before any child exists', async () => {
|
it('rejects a schema outside the subset loud, before any child exists', async () => {
|
||||||
const { ctx, parent } = await setup([])
|
const { ctx, parent } = await setup([])
|
||||||
await expect(ctx.subagents.start('spawn', structuredRequest(parent, {
|
await expect(ctx.subagents.start('spawn', structuredRequest(parent, {
|
||||||
outputSchema: { type: 'object', oneOf: [] } as unknown as StructuredOutputSchema,
|
outputSchema: { type: 'object', oneOf: [] } as unknown as ObjectJsonSchema,
|
||||||
}))).rejects.toThrow(/unsupported output schema/)
|
}))).rejects.toThrow(/unsupported JSON schema/)
|
||||||
expect(ctx.agents.get(SessionId('parent'))).toBeDefined()
|
expect(ctx.agents.get(SessionId('parent'))).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('a schema carrying non-JSON values fails as OutputSchemaError at the validation boundary', async () => {
|
it('a schema carrying non-JSON values fails as JsonSchemaError at the validation boundary', async () => {
|
||||||
const { ctx, parent } = await setup([])
|
const { ctx, parent } = await setup([])
|
||||||
// Semantic assertion runs before provider startup.
|
// Semantic assertion runs before provider startup.
|
||||||
await expect(ctx.subagents.start('spawn', structuredRequest(parent, {
|
await expect(ctx.subagents.start('spawn', structuredRequest(parent, {
|
||||||
outputSchema: { type: 'object', default: () => {} } as unknown as StructuredOutputSchema,
|
outputSchema: { type: 'object', default: () => {} } as unknown as ObjectJsonSchema,
|
||||||
}))).rejects.toThrow(/unsupported output schema.*annotation must be JSON data/)
|
}))).rejects.toThrow(/unsupported JSON schema.*annotation must be lossless JSON data/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('a post-execute BLOCK on the capture call denies the capture: log and result agree on failure', async () => {
|
it('a post-execute BLOCK on the capture call denies the capture: log and result agree on failure', async () => {
|
||||||
@@ -547,7 +547,7 @@ describe('in-process structured output', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('two concurrent structured children each see their OWN schema', async () => {
|
it('two concurrent structured children each see their OWN schema', async () => {
|
||||||
const otherSchema: StructuredOutputSchema = {
|
const otherSchema: ObjectJsonSchema = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: { verdict: { type: 'string', enum: ['real', 'bogus'] } },
|
properties: { verdict: { type: 'string', enum: ['real', 'bogus'] } },
|
||||||
required: ['verdict'],
|
required: ['verdict'],
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import { randomUUID } from 'node:crypto'
|
|||||||
import { Context, Service } from 'cordis'
|
import { Context, Service } from 'cordis'
|
||||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||||
import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools'
|
import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools'
|
||||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||||
@@ -242,7 +242,7 @@ export class SubagentService extends Service {
|
|||||||
}
|
}
|
||||||
this.assertCapabilities(provider, request)
|
this.assertCapabilities(provider, request)
|
||||||
assertSubagentMaxDepth(request.maxDepth)
|
assertSubagentMaxDepth(request.maxDepth)
|
||||||
if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema)
|
if (request.outputSchema !== undefined) assertObjectJsonSchema(request.outputSchema)
|
||||||
|
|
||||||
const parent = request.parent
|
const parent = request.parent
|
||||||
const run = await provider.start(request)
|
const run = await provider.start(request)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
|
|||||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import type { StructuredOutputSchema, ToolRestriction } from '@deepseek-ai/dsh-tools'
|
import type { ObjectJsonSchema, ToolRestriction } from '@deepseek-ai/dsh-tools'
|
||||||
|
|
||||||
/** Identifies one accepted subagent run across its lifecycle event pair. */
|
/** Identifies one accepted subagent run across its lifecycle event pair. */
|
||||||
export type SubagentRunId = Branded<'SubagentRunId'>
|
export type SubagentRunId = Branded<'SubagentRunId'>
|
||||||
@@ -70,11 +70,11 @@ export interface SubagentStartRequest {
|
|||||||
/** Per-child agent options (model and plugin-defined extension fields). */
|
/** Per-child agent options (model and plugin-defined extension fields). */
|
||||||
readonly agentOptions?: AgentOptions
|
readonly agentOptions?: AgentOptions
|
||||||
/**
|
/**
|
||||||
* Object-rooted JSON Schema within `assertSupportedOutputSchema`'s enforced subset. Start rejects
|
* Object-rooted JSON Schema within `assertObjectJsonSchema`'s enforced subset. Start rejects
|
||||||
* unsupported schemas or providers without the capability. Data must be plain host-realm JSON;
|
* unsupported schemas or providers without the capability. Data must be plain host-realm JSON;
|
||||||
* a successful child returns the matching value as {@link SubagentResult.structured}.
|
* a successful child returns the matching value as {@link SubagentResult.structured}.
|
||||||
*/
|
*/
|
||||||
readonly outputSchema?: StructuredOutputSchema
|
readonly outputSchema?: ObjectJsonSchema
|
||||||
/**
|
/**
|
||||||
* Optional absolute delegation-depth cap for the child being started: its
|
* Optional absolute delegation-depth cap for the child being started: its
|
||||||
* computed depth must be less than or equal to this non-negative safe
|
* computed depth must be less than or equal to this non-negative safe
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function statusLine(snapshot: TaskSnapshot): string {
|
|||||||
: `[status: ${snapshot.status}]`
|
: `[status: ${snapshot.status}]`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validate the non-empty constraint that SchemaSpec cannot express. */
|
/** Validate the non-empty constraint that ParameterSchemaSpec cannot express. */
|
||||||
function validateTaskId(value: string): TaskId {
|
function validateTaskId(value: string): TaskId {
|
||||||
if (value.length === 0) {
|
if (value.length === 0) {
|
||||||
throw new Error(`invalid task_id: expected a non-empty string, got ${JSON.stringify(value)}`)
|
throw new Error(`invalid task_id: expected a non-empty string, got ${JSON.stringify(value)}`)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const DESCRIPTION =
|
|||||||
+ '(not started), `in_progress` (being worked on now), `completed` (finished).'
|
+ '(not started), `in_progress` (being worked on now), `completed` (finished).'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate the value constraints the SchemaSpec can't express and build the canonical {@link
|
* Validate the value constraints the ParameterSchemaSpec can't express and build the canonical {@link
|
||||||
* TodoItem}[]: trimmed non-empty unique content and at most one in-progress item. The registry
|
* TodoItem}[]: trimmed non-empty unique content and at most one in-progress item. The registry
|
||||||
* has already enforced the status enum; the cast below records that guarantee.
|
* has already enforced the status enum; the cast below records that guarantee.
|
||||||
*/
|
*/
|
||||||
@@ -67,6 +67,7 @@ export function apply(ctx: Context): void {
|
|||||||
description: 'The COMPLETE task list, replacing any previous list.',
|
description: 'The COMPLETE task list, replacing any previous list.',
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: true,
|
||||||
properties: {
|
properties: {
|
||||||
content: { type: 'string', required: true, description: 'What the task is — a short imperative line.' },
|
content: { type: 'string', required: true, description: 'What the task is — a short imperative line.' },
|
||||||
status: {
|
status: {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function apply(ctx: Context): void {
|
|||||||
description: 'Questions to ask the user before continuing.',
|
description: 'Questions to ask the user before continuing.',
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: true,
|
||||||
properties: {
|
properties: {
|
||||||
id: { type: 'string', required: true, description: 'Stable id for this question; echoed in the answer.' },
|
id: { type: 'string', required: true, description: 'Stable id for this question; echoed in the answer.' },
|
||||||
question: { type: 'string', required: true, description: 'The specific question to ask the user.' },
|
question: { type: 'string', required: true, description: 'The specific question to ask the user.' },
|
||||||
@@ -39,6 +40,7 @@ export function apply(ctx: Context): void {
|
|||||||
description: 'Optional choices to show the user. If you recommend one, put it first and append "(Recommended)" to that label.',
|
description: 'Optional choices to show the user. If you recommend one, put it first and append "(Recommended)" to that label.',
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: true,
|
||||||
properties: {
|
properties: {
|
||||||
label: { type: 'string', required: true, description: 'Short user-facing option label.' },
|
label: { type: 'string', required: true, description: 'Short user-facing option label.' },
|
||||||
description: { type: 'string', description: 'One sentence explaining the tradeoff or impact.' },
|
description: { type: 'string', description: 'One sentence explaining the tradeoff or impact.' },
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ export function apply(ctx: Context, config: Config): void {
|
|||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: true,
|
||||||
required: true,
|
required: true,
|
||||||
description: 'The workflow identity block (plain JSON — never code).',
|
description: 'The workflow identity block (plain JSON — never code).',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -142,6 +143,7 @@ export function apply(ctx: Context, config: Config): void {
|
|||||||
description: 'Optional phase declarations matched by phase() calls.',
|
description: 'Optional phase declarations matched by phase() calls.',
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: true,
|
||||||
properties: {
|
properties: {
|
||||||
title: { type: 'string', required: true, description: 'The phase title phase() calls match by exact string.' },
|
title: { type: 'string', required: true, description: 'The phase title phase() calls match by exact string.' },
|
||||||
detail: { type: 'string', description: 'Optional one-line description of the phase.' },
|
detail: { type: 'string', description: 'Optional one-line description of the phase.' },
|
||||||
@@ -154,6 +156,7 @@ export function apply(ctx: Context, config: Config): void {
|
|||||||
},
|
},
|
||||||
args: {
|
args: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
additionalProperties: true,
|
||||||
description: 'Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}).',
|
description: 'Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}).',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,8 +15,8 @@
|
|||||||
import * as vm from 'node:vm'
|
import * as vm from 'node:vm'
|
||||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import { assertSupportedOutputSchema, OutputSchemaError } from '@deepseek-ai/dsh-tools'
|
import { assertObjectJsonSchema, JsonSchemaError } from '@deepseek-ai/dsh-tools'
|
||||||
import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
|
import type { ObjectJsonSchema } from '@deepseek-ai/dsh-tools'
|
||||||
import { isFatalWorkflowError, WorkflowError } from '@deepseek-ai/dsh-workflow'
|
import { isFatalWorkflowError, WorkflowError } from '@deepseek-ai/dsh-workflow'
|
||||||
import type {
|
import type {
|
||||||
WorkflowAgentEndInfo,
|
WorkflowAgentEndInfo,
|
||||||
@@ -350,7 +350,7 @@ export class WorkflowExecution {
|
|||||||
phase?: string
|
phase?: string
|
||||||
provider?: string
|
provider?: string
|
||||||
model?: string
|
model?: string
|
||||||
schema?: StructuredOutputSchema
|
schema?: ObjectJsonSchema
|
||||||
} {
|
} {
|
||||||
if (rawOpts === undefined) return {}
|
if (rawOpts === undefined) return {}
|
||||||
let opts: unknown
|
let opts: unknown
|
||||||
@@ -377,14 +377,14 @@ export class WorkflowExecution {
|
|||||||
throw new WorkflowError(`agent() option "${key}" must be a string`, 'INVALID_ARGUMENT')
|
throw new WorkflowError(`agent() option "${key}" must be a string`, 'INVALID_ARGUMENT')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let schema: StructuredOutputSchema | undefined
|
let schema: ObjectJsonSchema | undefined
|
||||||
if (record.schema !== undefined) {
|
if (record.schema !== undefined) {
|
||||||
try {
|
try {
|
||||||
assertSupportedOutputSchema(record.schema)
|
assertObjectJsonSchema(record.schema)
|
||||||
schema = record.schema
|
schema = record.schema
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
/* v8 ignore next -- defensive rethrow arm: assertSupportedOutputSchema only throws OutputSchemaError */
|
/* v8 ignore next -- defensive rethrow arm: assertObjectJsonSchema only throws JsonSchemaError */
|
||||||
if (!(error instanceof OutputSchemaError)) throw error
|
if (!(error instanceof JsonSchemaError)) throw error
|
||||||
throw new WorkflowError(`agent() schema is outside the supported subset — ${error.message}`, 'UNSUPPORTED_SCHEMA', { cause: error })
|
throw new WorkflowError(`agent() schema is outside the supported subset — ${error.message}`, 'UNSUPPORTED_SCHEMA', { cause: error })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||||
import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
|
import type { ObjectJsonSchema } from '@deepseek-ai/dsh-tools'
|
||||||
import type { WorkflowMeta } from '@deepseek-ai/dsh-workflow'
|
import type { WorkflowMeta } from '@deepseek-ai/dsh-workflow'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,7 +41,7 @@ export interface ChildStartRequest {
|
|||||||
/** The child's prompt text. */
|
/** The child's prompt text. */
|
||||||
prompt: string
|
prompt: string
|
||||||
/** The structured-output schema, if the call passed one (already subset-checked). */
|
/** The structured-output schema, if the call passed one (already subset-checked). */
|
||||||
schema?: StructuredOutputSchema
|
schema?: ObjectJsonSchema
|
||||||
/** The per-child provider override, if the call passed one. */
|
/** The per-child provider override, if the call passed one. */
|
||||||
provider?: string
|
provider?: string
|
||||||
/** The per-child model override, if the call passed one. */
|
/** The per-child model override, if the call passed one. */
|
||||||
|
|||||||
@@ -91,8 +91,10 @@
|
|||||||
{ "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventTrace", "source": "packages/session-query/session-query/src/types.ts" },
|
{ "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventTrace", "source": "packages/session-query/session-query/src/types.ts" },
|
||||||
|
|
||||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolDefinition", "source": "packages/core/tools/src/index.ts" },
|
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolDefinition", "source": "packages/core/tools/src/index.ts" },
|
||||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaProp", "source": "packages/core/tools/src/schema.ts" },
|
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ValueSchemaSpec", "source": "packages/core/tools/src/schema.ts" },
|
||||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaSpec", "source": "packages/core/tools/src/schema.ts" },
|
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ParameterPropertySpec", "source": "packages/core/tools/src/schema.ts" },
|
||||||
|
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ParameterSchemaSpec", "source": "packages/core/tools/src/schema.ts" },
|
||||||
|
{ "doc": "docs/core-data-structures/tools.md", "symbol": "InferValue", "source": "packages/core/tools/src/schema.ts" },
|
||||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "InferArgs", "source": "packages/core/tools/src/schema.ts" },
|
{ "doc": "docs/core-data-structures/tools.md", "symbol": "InferArgs", "source": "packages/core/tools/src/schema.ts" },
|
||||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionToken", "source": "packages/core/tools/src/index.ts" },
|
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionToken", "source": "packages/core/tools/src/index.ts" },
|
||||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionInput", "source": "packages/core/tools/src/index.ts" },
|
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionInput", "source": "packages/core/tools/src/index.ts" },
|
||||||
@@ -104,10 +106,10 @@
|
|||||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionResult", "source": "packages/core/tools/src/index.ts" },
|
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionResult", "source": "packages/core/tools/src/index.ts" },
|
||||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "PreToolDecision", "source": "packages/core/tools/src/index.ts" },
|
{ "doc": "docs/core-data-structures/tools.md", "symbol": "PreToolDecision", "source": "packages/core/tools/src/index.ts" },
|
||||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "PostToolDecision", "source": "packages/core/tools/src/index.ts" },
|
{ "doc": "docs/core-data-structures/tools.md", "symbol": "PostToolDecision", "source": "packages/core/tools/src/index.ts" },
|
||||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "StructuredScalar", "source": "packages/core/tools/src/json-schema.ts" },
|
{ "doc": "docs/core-data-structures/tools.md", "symbol": "JsonSchemaScalar", "source": "packages/core/tools/src/json-schema.ts" },
|
||||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "StructuredSchemaType", "source": "packages/core/tools/src/json-schema.ts" },
|
{ "doc": "docs/core-data-structures/tools.md", "symbol": "JsonSchemaType", "source": "packages/core/tools/src/json-schema.ts" },
|
||||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "StructuredSchemaNode", "source": "packages/core/tools/src/json-schema.ts" },
|
{ "doc": "docs/core-data-structures/tools.md", "symbol": "JsonSchemaNode", "source": "packages/core/tools/src/json-schema.ts" },
|
||||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "StructuredOutputSchema", "source": "packages/core/tools/src/json-schema.ts" },
|
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ObjectJsonSchema", "source": "packages/core/tools/src/json-schema.ts" },
|
||||||
|
|
||||||
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionOption", "source": "packages/ui/user-interaction/src/index.ts" },
|
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionOption", "source": "packages/ui/user-interaction/src/index.ts" },
|
||||||
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionItem", "source": "packages/ui/user-interaction/src/index.ts" },
|
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionItem", "source": "packages/ui/user-interaction/src/index.ts" },
|
||||||
|
|||||||
Reference in New Issue
Block a user