From 8500974fd466b2faadeda3c95cf9574e1a934a74 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 01:11:55 +0800 Subject: [PATCH 001/103] feat: unify JSON value schema DSL --- .../2026-06-11-custom-schema-dsl.md | 4 +- .../2026-06-11-runtime-arg-validation.md | 6 +- ...20-unified-json-value-schema-dsl.i18n.yaml | 6 + ...026-07-20-unified-json-value-schema-dsl.md | 32 + ...-07-20-unified-json-value-schema-dsl.zh.md | 32 + .../feature/2026-07-05-dynamic-workflows.md | 4 +- ...6-07-08-self-referential-cordis-toolset.md | 4 +- ...2026-06-20-core-data-structures-catalog.md | 2 +- .../2026-06-11-property-based-testing.md | 2 +- ...prune-unimplemented-subagent-vocabulary.md | 2 +- docs/config-catalog.md | 2 +- docs/cookbook/adding-a-tool.i18n.yaml | 4 +- docs/cookbook/adding-a-tool.md | 2 +- docs/cookbook/adding-a-tool.zh.md | 2 +- docs/cordis-catalog/events.md | 10 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/core.md | 4 +- docs/core-data-structures/subagent.md | 4 +- docs/core-data-structures/tools.md | 136 +++-- docs/event-producer-consumer.md | 10 +- docs/tool-catalog.md | 10 +- docs/user/develop/basic/tool.i18n.yaml | 4 +- docs/user/develop/basic/tool.md | 22 +- docs/user/develop/basic/tool.zh.md | 22 +- .../system-prompt.expected.md | 52 +- .../tool-schemas.expected.json | 8 +- .../both-mode-turn/system-prompt.expected.md | 44 +- .../both-mode-turn/tool-schemas.expected.json | 6 +- .../code-mode-turn/system-prompt.expected.md | 44 +- .../system-prompt.expected.md | 44 +- .../tool-schemas.expected.json | 12 +- .../tool-schemas.expected.json | 12 +- .../skill-load/tool-schemas.expected.json | 6 +- .../text-turn/tool-schemas.expected.json | 6 +- .../tool-schemas.expected.json | 6 +- packages/bash/tool-bash/src/index.ts | 2 +- packages/bash/tool-bash/tests/tools.spec.ts | 4 +- .../cordis/tool-cordis/src/api-catalog.ts | 34 +- packages/cordis/tool-cordis/src/guard.ts | 238 ++++++-- packages/cordis/tool-cordis/src/index.ts | 6 +- .../cordis/tool-cordis/tests/mount.spec.ts | 146 ++++- packages/core/tools/README.md | 8 +- packages/core/tools/src/index.ts | 39 +- packages/core/tools/src/json-schema.ts | 495 ++++++++------- packages/core/tools/src/schema.ts | 573 ++++++++++-------- packages/core/tools/src/ts-types.ts | 72 ++- packages/core/tools/tests/json-schema.spec.ts | 482 ++++++++------- packages/core/tools/tests/properties.spec.ts | 72 ++- packages/core/tools/tests/schema.spec.ts | 138 +++++ packages/core/tools/tests/tools.spec.ts | 96 +-- packages/core/tools/tests/ts-types.spec.ts | 59 +- .../subagent-inprocess/src/structured.ts | 8 +- .../tests/structured.spec.ts | 16 +- packages/subagent/subagent/src/index.ts | 4 +- packages/subagent/subagent/src/types.ts | 6 +- packages/tasks/tool-tasks/src/index.ts | 2 +- packages/todo/tool-todo/src/index.ts | 3 +- packages/ui/tool-ask-user/src/index.ts | 2 + packages/workflow/tool-workflow/src/index.ts | 3 + .../workflow-workerthread/src/runtime.ts | 14 +- .../workflow-workerthread/src/types.ts | 4 +- scripts/type-equiv.manifest.json | 14 +- 62 files changed, 1929 insertions(+), 1179 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md create mode 100644 .agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md create mode 100644 packages/core/tools/tests/schema.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.md b/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.md index bf8c02140a..41b72f1551 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.md +++ b/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.md @@ -8,7 +8,7 @@ Tool parameters must reach the model as standard JSON Schema while giving tool a ## Decision -A small custom DSL in dsh-tools: `SchemaSpec` (per-property specs with `required: true` booleans), type-level `InferArgs` 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` 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 @@ -17,5 +17,5 @@ A small custom DSL in dsh-tools: `SchemaSpec` (per-property specs with `required ## 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). -- 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. diff --git a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.md b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.md index 454f12d0af..94b0c6af60 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.md +++ b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.md @@ -4,13 +4,13 @@ Status: implemented ## Problem -`defineTool` ([the custom schema DSL](2026-06-11-custom-schema-dsl.md)) gives tool authors a typed `execute(args)` via the `InferArgs` 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` 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 -`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 diff --git a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml new file mode 100644 index 0000000000..f434c81118 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md new file mode 100644 index 0000000000..ab7bb26840 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md @@ -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` and `InferArgs

` 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. diff --git a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md new file mode 100644 index 0000000000..479699fc2d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md @@ -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` 和 `InferArgs

` 根据同一份声明推导 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 值和类型推导。 diff --git a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md index 599c895ae2..fcd0b66062 100644 --- a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md @@ -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. -`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 @@ -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. - **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). -- **`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. - **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. diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md index 144bdd018f..2038b2b719 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md @@ -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. -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 @@ -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` | |---|---|---| -| 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 | | 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 | diff --git a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md index 21b86b1812..99ab46ef68 100644 --- a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md +++ b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md @@ -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: - 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. - The tool-presentation vocabulary (`ToolCallView`/`ToolResultView`, …), the `SessionPersistence` durability seam, and bash vocabulary are sub-pages. diff --git a/.agents/notes/implemented/testing/2026-06-11-property-based-testing.md b/.agents/notes/implemented/testing/2026-06-11-property-based-testing.md index 06350753cd..5109aa1c80 100644 --- a/.agents/notes/implemented/testing/2026-06-11-property-based-testing.md +++ b/.agents/notes/implemented/testing/2026-06-11-property-based-testing.md @@ -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-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. ## Consequences diff --git a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md index 582673f185..1f07492c9c 100644 --- a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md +++ b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md @@ -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. - **`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 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5bc5657d2a..44e27bb572 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1303,7 +1303,7 @@ export interface Config { 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` diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml index 070cc45f93..288ee96446 100644 --- a/docs/cookbook/adding-a-tool.i18n.yaml +++ b/docs/cookbook/adding-a-tool.i18n.yaml @@ -2,5 +2,5 @@ # 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 -adding-a-tool.md: a45315dc0ec92ab28963c2aca32dffcf5f778dcd -adding-a-tool.zh.md: f574957ddd0e42cedc93ddc0f3270110a8f110c5 +adding-a-tool.md: 94cb4fcfa9a0155fd57ef18f9a855a264c2eff84 +adding-a-tool.zh.md: 637dc3381765e57c2d0420164ac13fde7fb590fe diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index a45315dc0e..94cb4fcfa9 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -35,7 +35,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool (w ## 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. - **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. diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md index f574957ddd..637dc33817 100644 --- a/docs/cookbook/adding-a-tool.zh.md +++ b/docs/cookbook/adding-a-tool.zh.md @@ -35,7 +35,7 @@ export function apply(ctx: Context) { ## 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 其所属副作用并注册替代品;回调闭包内的可变状态仍是普通的插件状态。 - **执行身份受保护。** 注册表在一次递归遍历中将 `arguments` 物化为分离的无损 JSON,在策略开始前冻结该值,并分配一个不透明的 `exec.token`;`callId`、`name`、`arguments`、`agent`、`token` 以及可选的外层传输 `parent` token 在整个分发过程中保持不可变。`parent` 仅用于身份标识,不暴露活跃的外层执行。请将 `args` 视为只读输入。around-dispatch 包装器只能添加、替换或移除 `exec.signal`,以施加取消或截止时间。 - **抛出异常或返回非 JSON 数据意味着 `isError`。** 注册表捕获异常,并在观察者运行前物化最终结果。格式错误或非 JSON 的结果变为 `{ isError: true }`,防止出现无法记录的活跃成功。基础设施故障请抛异常;当模型需要解读领域失败时,请在结果文本中报告。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index a213c9e11a..cde1c4340b 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -760,7 +760,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai '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 @@ -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) -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 @@ -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) -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 @@ -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) -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 @@ -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) -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/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 5fa14917e1..48feaa92a6 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1361,7 +1361,7 @@ async execute(exec: ToolExecutionInput): Promise 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` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index dfdc258cb0..460fdc7a48 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -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** 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 | |---|---| @@ -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. -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)**. diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index bd4324e697..1b048b94d3 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -63,11 +63,11 @@ interface SubagentStartRequest { /** Per-child agent options (model and plugin-defined extension fields). */ 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; * 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 * computed depth must be less than or equal to this non-negative safe diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 9e3d698440..20a74e0190 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -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. -## 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) ```ts type-equiv -/** One schema-spec property entry. */ -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 - /** Enum of allowed values (strings only). */ - enum?: string[] - /** - * Model-visible JSON Schema default annotation. Validation does not apply it; - * dynamic tool mounts may supply it even though first-party definitions do not. - */ - default?: unknown - /** Nested properties for type: 'object'. */ - properties?: SchemaSpec - /** Items schema for type: 'array'. */ - items?: SchemaProp -} +/** One author-facing schema for any lossless JSON value root. */ +type ValueSchemaSpec = + | StringValueSchemaSpec + | NumberValueSchemaSpec + | IntegerValueSchemaSpec + | BooleanValueSchemaSpec + | NullValueSchemaSpec + | ArrayValueSchemaSpec + | ObjectValueSchemaSpec + | JsonValueSchemaSpec + | OneOfValueSchemaSpec +``` + +```ts type-equiv +/** One implicit parameter-root property, optionally required. */ +type ParameterPropertySpec = ValueSchemaSpec & { required?: true } ``` ```ts type-equiv /** - * The author-facing parameter schema: a shallow map of property name to - * {@link SchemaProp}. Required-ness is a per-property boolean (`required: - * true`), not a separate array. + * Tool parameter schema. The map itself is an implicit open object root; + * requiredness remains a per-property `required: true` annotation. */ -type SchemaSpec = Record +type ParameterSchemaSpec = Record ``` -`SchemaType` is the primitive union `'string' | 'number' | 'boolean' | 'object' | 'array'`. `InferArgs` 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` honors literal constraints and object openness; `InferArgs

` turns per-property requiredness into required and optional keys: ```ts type-equiv /** - * Infer the TS argument type for a complete {@link SchemaSpec}. - * - * Properties marked `required: true` are required keys; all others are - * genuinely optional keys (`?`), so callers may omit them entirely. - * - * Example: - * ```ts - * type Args = InferArgs<{ path: { type: 'string'; required: true }; limit: { type: 'number' } }> - * // → { path: string; limit?: number } - * ``` + * Infer the TypeScript value accepted by an author-facing value schema. + * Output schemas may therefore infer object, array, scalar, or null roots. */ -type InferArgs = Simplify< - & { [K in RequiredKeys]: InferPropValue } - & { [K in Exclude>]?: InferPropValue } -> +type InferValue = + S extends StringValueSchemaSpec ? InferScalar : + S extends NumberValueSchemaSpec | IntegerValueSchemaSpec ? InferScalar : + S extends BooleanValueSchemaSpec ? InferScalar : + S extends NullValueSchemaSpec ? null : + S extends ArrayValueSchemaSpec + ? S extends { items: infer I extends ValueSchemaSpec } ? InferValue[] : JsonValue[] + : S extends ObjectValueSchemaSpec ? InferObject : + S extends JsonValueSchemaSpec ? JsonValue : + S extends OneOfValueSchemaSpec ? InferValue : + never ``` -`defineTool({ name, description, parameters, execute, … })` ties it together: `parameters` is a `SchemaSpec`, `execute(args, exec)` gets `args: InferArgs`, 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 = InferProperties +``` + +`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. @@ -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. -## 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 -/** The scalar values `enum`/`const` may carry (finite numbers only). */ -type StructuredScalar = string | number | boolean | null +/** Scalar JSON values supported by `enum` and `const`. */ +type JsonSchemaScalar = string | number | boolean | null ``` ```ts type-equiv -/** The `type` keywords the subset accepts. */ -type StructuredSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null' +/** Single-type keywords accepted by the enforced subset. */ +type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null' ``` ```ts type-equiv /** - * One node of the structured-output schema subset. Recursive via `properties` - * and `items`; see the module doc for the exact keyword semantics. + * One raw JSON Schema node in the enforced subset. The optional fields express + * the external wire shape; {@link assertSupportedJsonSchema} rejects invalid + * combinations before a caller treats the node as trusted. */ -interface StructuredSchemaNode { - type: StructuredSchemaType +interface JsonSchemaNode { + /** Omit with no constraints for any JSON value, or use `oneOf`. */ + type?: JsonSchemaType + /** Exactly one branch must validate; at least two branches are required. */ + oneOf?: JsonSchemaNode[] /** Nested property schemas (`type: 'object'` only). */ - properties?: Record + properties?: Record /** Required property names; each must appear in `properties`. */ required?: string[] - /** `false` rejects undeclared keys; absent/`true` allows them (JSON Schema default). */ + /** `false` rejects undeclared keys; absent/`true` follows JSON Schema's open default. */ additionalProperties?: boolean - /** Item schema (`type: 'array'` only); absent ⇒ any JSON items. */ - items?: StructuredSchemaNode - /** Allowed values (scalar types only). */ - enum?: StructuredScalar[] - /** The single allowed value (scalar types only). */ - const?: StructuredScalar + /** Item schema (`type: 'array'` only); absent accepts any JSON item. */ + items?: JsonSchemaNode + /** Allowed values for a scalar node. */ + enum?: JsonSchemaScalar[] + /** The single allowed value for a scalar node. */ + const?: JsonSchemaScalar /** Annotation, ignored for validation. */ description?: string /** Annotation, ignored for validation. */ title?: string - /** Annotation, ignored for validation (must still be JSON data). */ - default?: unknown - /** Annotation, ignored for validation (must still be JSON data). */ - examples?: unknown + /** Annotation, ignored for validation but required to be lossless JSON. */ + default?: JsonValue + /** Annotation, ignored for validation but required to be lossless JSON. */ + examples?: JsonValue } ``` -A 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 -/** A structured-output schema: an OBJECT-rooted {@link StructuredSchemaNode}. */ -type StructuredOutputSchema = StructuredSchemaNode & { type: 'object' } +/** A consumer-constrained object-rooted schema. */ +type ObjectJsonSchema = JsonSchemaNode & { type: 'object' } ``` ## Tool-presentation UI vocabulary diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 99acf07d24..6185522793 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -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) | | `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`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:116`](../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/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/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/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/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: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: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: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: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-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`) | - | diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 6196a71521..eaf24e38a7 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -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.", "items": { "type": "object", + "additionalProperties": true, "properties": { "id": { "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.", "items": { "type": "object", + "additionalProperties": true, "properties": { "label": { "type": "string", @@ -201,7 +203,7 @@ Source: [`packages/cordis/tool-cordis/src/index.ts`](../packages/cordis/tool-cor ### `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:]`, 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:]`, 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 { @@ -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.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -720,6 +723,7 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "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.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -769,7 +774,8 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim }, "args": { "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": [ diff --git a/docs/user/develop/basic/tool.i18n.yaml b/docs/user/develop/basic/tool.i18n.yaml index d2f4343cf1..b741ccf65f 100644 --- a/docs/user/develop/basic/tool.i18n.yaml +++ b/docs/user/develop/basic/tool.i18n.yaml @@ -2,5 +2,5 @@ # 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 -tool.md: 416733bcb584fa5303a8b3ba5e6e904302e7f992 -tool.zh.md: fce9a7d9b973853c8b4fb9ae2c034e749d8da999 +tool.md: 17adbfc5f7eb804856dfe39b4d2b4a65374b6414 +tool.zh.md: 8857e16ca836dfa5b158a68581bd3c715dcb1ac5 diff --git a/docs/user/develop/basic/tool.md b/docs/user/develop/basic/tool.md index 416733bcb5..17adbfc5f7 100644 --- a/docs/user/develop/basic/tool.md +++ b/docs/user/develop/basic/tool.md @@ -37,10 +37,11 @@ export function apply(ctx: Context) { ```ts export const parameters = { path: { type: 'string', required: true }, - limit: { type: 'number' }, + limit: { type: 'integer' }, 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 @@ -49,7 +50,7 @@ export const parameters = { export const parameters = { 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 @@ -58,13 +59,14 @@ export const parameters = { export const parameters = { options: { type: 'object', + additionalProperties: true, properties: { timeout: { type: 'number' }, retries: { type: 'number' }, }, }, } -// Inferred type: { options?: { timeout?: number; retries?: number } } +// The declared fields are inferred; additional JSON-valued keys are allowed. ``` ### Arrays @@ -83,12 +85,16 @@ export const parameters = { | 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 | | `description` | `string` | Description sent to the model | -| `enum` | `string[]` | Allowed string values | -| `properties` | `SchemaSpec` | Nested properties for an object | -| `items` | `SchemaProp` | Element schema for an array | +| `enum` / `const` | matching scalar values | Allowed literal values, checked at author and runtime boundaries | +| `properties` | `ParameterSchemaSpec` | Nested properties for an object | +| `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 diff --git a/docs/user/develop/basic/tool.zh.md b/docs/user/develop/basic/tool.zh.md index fce9a7d9b9..8857e16ca8 100644 --- a/docs/user/develop/basic/tool.zh.md +++ b/docs/user/develop/basic/tool.zh.md @@ -37,10 +37,11 @@ export function apply(ctx: Context) { ```ts export const parameters = { path: { type: 'string', required: true }, - limit: { type: 'number' }, + limit: { type: 'integer' }, 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 = { 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 = { options: { type: 'object', + additionalProperties: true, properties: { timeout: { 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` | 标记为必填(影响类型推导) | | `description` | `string` | 发送给模型的描述 | -| `enum` | `string[]` | 允许的枚举值 | -| `properties` | `SchemaSpec` | 嵌套属性(type 为 object 时) | -| `items` | `SchemaProp` | 数组元素 schema(type 为 array 时) | +| `enum` / `const` | 匹配类型的标量值 | 允许的字面量值,在编写和运行时边界校验 | +| `properties` | `ParameterSchemaSpec` | 对象的嵌套属性 | +| `additionalProperties` | `true \| false` | 每个显式对象节点都必须声明 | +| `items` | `ValueSchemaSpec` | 数组的元素 schema | +| `oneOf` | 至少两个 `ValueSchemaSpec` 分支 | 要求恰好匹配一个分支;代替 `type` 使用 | + +外层 `parameters` 映射是一个隐式的开放对象。显式嵌套对象需自行选择是否开放;不通过 `defineTool` 注册的原始 JSON Schema 保持 JSON Schema 的默认开放语义。 ## execute 函数 diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index e5773c1319..3e111b8ba2 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -36,6 +36,8 @@ Pass `run_code` the body of an async TypeScript function (erasable syntax only The available tools: ```ts +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } + 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]` — 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: { @@ -53,31 +55,31 @@ declare const tools: { 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. */ justification?: string; - }): Promise; + } & Record): Promise; /** 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: { /** Limit the report to one section. Omit for all sections. */ 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". */ name?: string; - }): Promise; - /** 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:]`, 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. */ + } & Record): Promise; + /** 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:]`, 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: { /** Body of an async JS function; must `return` the plugin to mount. */ code: string; - }): Promise; + } & Record): Promise; /** 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: { /** The dynamic mount id returned by cordis_mount (e.g. "dyn-1"). */ id: string; - }): Promise; + } & Record): Promise; /** 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: { /** The concrete completion objective inferred from the direct human request. */ objective: string; /** Optional positive safe-integer limit on automatic continuation rounds. */ max_goal_rounds?: number; - }): Promise; + } & Record): Promise; /** Edit an existing UTF-8 text file by replacing literal text. */ edit(args: { /** Path to edit, resolved by the filesystem backend. */ @@ -92,16 +94,16 @@ declare const tools: { 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. */ justification?: string; - }): Promise; + } & Record): Promise; /** 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): Promise; + get_goal(args: Record): Promise; /** 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: { /** The immutable completion objective for every fresh Ralph round. */ objective: string; /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */ maxRounds?: number; - }): Promise; + } & Record): Promise; /** Read a UTF-8 text file and return line-numbered content. */ read(args: { /** Path to read, resolved by the filesystem backend. */ @@ -110,12 +112,12 @@ declare const tools: { offset?: number; /** Maximum number of lines to return. Defaults to 2000. */ limit?: number; - }): Promise; + } & Record): Promise; /** 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: { /** The exact skill name from the available skills list. */ name: string; - }): Promise; + } & Record): Promise; /** 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: { /** A short (3-5 word) description of the delegated task, for display. */ @@ -124,7 +126,7 @@ declare const tools: { prompt: string; /** Run as a background task and return its id; collect with task_output or stop with task_kill. */ run_in_background?: boolean; - }): Promise; + } & Record): Promise; /** 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: { /** A short (3-5 word) description of the delegated task, for display. */ @@ -133,16 +135,16 @@ declare const tools: { prompt: string; /** Run as a background task and return its id; collect with task_output or stop with task_kill. */ run_in_background?: boolean; - }): Promise; + } & Record): Promise; /** 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 id returned by the tool that started the background work. */ task_id: string; /** Optional short reason, recorded in the log and forwarded to the task. */ reason?: string; - }): Promise; + } & Record): Promise; /** List your background tasks (running and finished) with their ids, kinds, and statuses. */ - task_list(args: Record): Promise; + task_list(args: Record): Promise; /** 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 id returned by the tool that started the background work. */ @@ -151,7 +153,7 @@ declare const tools: { wait?: boolean; /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */ timeout_ms?: number; - }): Promise; + } & Record): Promise; /** 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: { /** The COMPLETE task list, replacing any previous list. */ @@ -160,8 +162,8 @@ declare const tools: { content: string; /** pending (not started) | in_progress (now) | completed (done). */ status: "pending" | "in_progress" | "completed"; - })[]; - }): Promise; + } & Record)[]; + } & Record): Promise; /** 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: { /** Exact id returned by get_goal. */ @@ -176,7 +178,7 @@ declare const tools: { max_goal_rounds?: number; /** Concrete blocking condition; required only with action blocked. */ blocked_reason?: string; - }): Promise; + } & Record): Promise; /** 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 ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — 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` — 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` — 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: { /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */ @@ -190,7 +192,7 @@ declare const tools: { /** Optional guidance on when this workflow applies. */ whenToUse?: string; /** Optional phase declarations matched by phase() calls. */ - phases?: { + phases?: ({ /** The phase title phase() calls match by exact string. */ title: string; /** Optional one-line description of the phase. */ @@ -199,11 +201,11 @@ declare const tools: { provider?: string; /** Optional model override this phase is expected to use. */ model?: string; - }[]; - }; + } & Record)[]; + } & Record; /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */ - args?: Record; - }): Promise; + args?: Record; + } & Record): Promise; /** Create or fully replace a UTF-8 text file. */ write(args: { /** Path to write, resolved by the filesystem backend. */ @@ -214,6 +216,6 @@ declare const tools: { 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. */ justification?: string; - }): Promise; + } & Record): Promise; } ``` diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index 71597bc9c3..95cdd43b83 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -72,7 +72,7 @@ }, { "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:]`, 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:]`, 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": { "type": "object", "properties": { @@ -361,6 +361,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -446,6 +447,7 @@ "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -464,6 +466,7 @@ "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -495,7 +498,8 @@ }, "args": { "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": [ diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md index ac1114d0a3..2ec278e294 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md @@ -36,6 +36,8 @@ Pass `run_code` the body of an async TypeScript function (erasable syntax only The available tools: ```ts +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } + 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]` — 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: { @@ -53,14 +55,14 @@ declare const tools: { 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. */ justification?: string; - }): Promise; + } & Record): Promise; /** 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: { /** The concrete completion objective inferred from the direct human request. */ objective: string; /** Optional positive safe-integer limit on automatic continuation rounds. */ max_goal_rounds?: number; - }): Promise; + } & Record): Promise; /** Edit an existing UTF-8 text file by replacing literal text. */ edit(args: { /** Path to edit, resolved by the filesystem backend. */ @@ -75,16 +77,16 @@ declare const tools: { 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. */ justification?: string; - }): Promise; + } & Record): Promise; /** 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): Promise; + get_goal(args: Record): Promise; /** 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: { /** The immutable completion objective for every fresh Ralph round. */ objective: string; /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */ maxRounds?: number; - }): Promise; + } & Record): Promise; /** Read a UTF-8 text file and return line-numbered content. */ read(args: { /** Path to read, resolved by the filesystem backend. */ @@ -93,12 +95,12 @@ declare const tools: { offset?: number; /** Maximum number of lines to return. Defaults to 2000. */ limit?: number; - }): Promise; + } & Record): Promise; /** 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: { /** The exact skill name from the available skills list. */ name: string; - }): Promise; + } & Record): Promise; /** 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: { /** A short (3-5 word) description of the delegated task, for display. */ @@ -107,7 +109,7 @@ declare const tools: { prompt: string; /** Run as a background task and return its id; collect with task_output or stop with task_kill. */ run_in_background?: boolean; - }): Promise; + } & Record): Promise; /** 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: { /** A short (3-5 word) description of the delegated task, for display. */ @@ -116,16 +118,16 @@ declare const tools: { prompt: string; /** Run as a background task and return its id; collect with task_output or stop with task_kill. */ run_in_background?: boolean; - }): Promise; + } & Record): Promise; /** 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 id returned by the tool that started the background work. */ task_id: string; /** Optional short reason, recorded in the log and forwarded to the task. */ reason?: string; - }): Promise; + } & Record): Promise; /** List your background tasks (running and finished) with their ids, kinds, and statuses. */ - task_list(args: Record): Promise; + task_list(args: Record): Promise; /** 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 id returned by the tool that started the background work. */ @@ -134,7 +136,7 @@ declare const tools: { wait?: boolean; /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */ timeout_ms?: number; - }): Promise; + } & Record): Promise; /** 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: { /** The COMPLETE task list, replacing any previous list. */ @@ -143,8 +145,8 @@ declare const tools: { content: string; /** pending (not started) | in_progress (now) | completed (done). */ status: "pending" | "in_progress" | "completed"; - })[]; - }): Promise; + } & Record)[]; + } & Record): Promise; /** 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: { /** Exact id returned by get_goal. */ @@ -159,7 +161,7 @@ declare const tools: { max_goal_rounds?: number; /** Concrete blocking condition; required only with action blocked. */ blocked_reason?: string; - }): Promise; + } & Record): Promise; /** 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 ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — 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` — 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` — 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: { /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */ @@ -173,7 +175,7 @@ declare const tools: { /** Optional guidance on when this workflow applies. */ whenToUse?: string; /** Optional phase declarations matched by phase() calls. */ - phases?: { + phases?: ({ /** The phase title phase() calls match by exact string. */ title: string; /** Optional one-line description of the phase. */ @@ -182,11 +184,11 @@ declare const tools: { provider?: string; /** Optional model override this phase is expected to use. */ model?: string; - }[]; - }; + } & Record)[]; + } & Record; /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */ - args?: Record; - }): Promise; + args?: Record; + } & Record): Promise; /** Create or fully replace a UTF-8 text file. */ write(args: { /** Path to write, resolved by the filesystem backend. */ @@ -197,6 +199,6 @@ declare const tools: { 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. */ justification?: string; - }): Promise; + } & Record): Promise; } ``` diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json index ab52ec415d..8406669edc 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json @@ -304,6 +304,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -389,6 +390,7 @@ "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -407,6 +409,7 @@ "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -438,7 +441,8 @@ }, "args": { "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": [ diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index ac1114d0a3..2ec278e294 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -36,6 +36,8 @@ Pass `run_code` the body of an async TypeScript function (erasable syntax only The available tools: ```ts +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } + 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]` — 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: { @@ -53,14 +55,14 @@ declare const tools: { 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. */ justification?: string; - }): Promise; + } & Record): Promise; /** 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: { /** The concrete completion objective inferred from the direct human request. */ objective: string; /** Optional positive safe-integer limit on automatic continuation rounds. */ max_goal_rounds?: number; - }): Promise; + } & Record): Promise; /** Edit an existing UTF-8 text file by replacing literal text. */ edit(args: { /** Path to edit, resolved by the filesystem backend. */ @@ -75,16 +77,16 @@ declare const tools: { 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. */ justification?: string; - }): Promise; + } & Record): Promise; /** 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): Promise; + get_goal(args: Record): Promise; /** 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: { /** The immutable completion objective for every fresh Ralph round. */ objective: string; /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */ maxRounds?: number; - }): Promise; + } & Record): Promise; /** Read a UTF-8 text file and return line-numbered content. */ read(args: { /** Path to read, resolved by the filesystem backend. */ @@ -93,12 +95,12 @@ declare const tools: { offset?: number; /** Maximum number of lines to return. Defaults to 2000. */ limit?: number; - }): Promise; + } & Record): Promise; /** 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: { /** The exact skill name from the available skills list. */ name: string; - }): Promise; + } & Record): Promise; /** 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: { /** A short (3-5 word) description of the delegated task, for display. */ @@ -107,7 +109,7 @@ declare const tools: { prompt: string; /** Run as a background task and return its id; collect with task_output or stop with task_kill. */ run_in_background?: boolean; - }): Promise; + } & Record): Promise; /** 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: { /** A short (3-5 word) description of the delegated task, for display. */ @@ -116,16 +118,16 @@ declare const tools: { prompt: string; /** Run as a background task and return its id; collect with task_output or stop with task_kill. */ run_in_background?: boolean; - }): Promise; + } & Record): Promise; /** 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 id returned by the tool that started the background work. */ task_id: string; /** Optional short reason, recorded in the log and forwarded to the task. */ reason?: string; - }): Promise; + } & Record): Promise; /** List your background tasks (running and finished) with their ids, kinds, and statuses. */ - task_list(args: Record): Promise; + task_list(args: Record): Promise; /** 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 id returned by the tool that started the background work. */ @@ -134,7 +136,7 @@ declare const tools: { wait?: boolean; /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */ timeout_ms?: number; - }): Promise; + } & Record): Promise; /** 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: { /** The COMPLETE task list, replacing any previous list. */ @@ -143,8 +145,8 @@ declare const tools: { content: string; /** pending (not started) | in_progress (now) | completed (done). */ status: "pending" | "in_progress" | "completed"; - })[]; - }): Promise; + } & Record)[]; + } & Record): Promise; /** 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: { /** Exact id returned by get_goal. */ @@ -159,7 +161,7 @@ declare const tools: { max_goal_rounds?: number; /** Concrete blocking condition; required only with action blocked. */ blocked_reason?: string; - }): Promise; + } & Record): Promise; /** 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 ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — 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` — 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` — 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: { /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */ @@ -173,7 +175,7 @@ declare const tools: { /** Optional guidance on when this workflow applies. */ whenToUse?: string; /** Optional phase declarations matched by phase() calls. */ - phases?: { + phases?: ({ /** The phase title phase() calls match by exact string. */ title: string; /** Optional one-line description of the phase. */ @@ -182,11 +184,11 @@ declare const tools: { provider?: string; /** Optional model override this phase is expected to use. */ model?: string; - }[]; - }; + } & Record)[]; + } & Record; /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */ - args?: Record; - }): Promise; + args?: Record; + } & Record): Promise; /** Create or fully replace a UTF-8 text file. */ write(args: { /** Path to write, resolved by the filesystem backend. */ @@ -197,6 +199,6 @@ declare const tools: { 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. */ justification?: string; - }): Promise; + } & Record): Promise; } ``` diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md index ac1114d0a3..2ec278e294 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md @@ -36,6 +36,8 @@ Pass `run_code` the body of an async TypeScript function (erasable syntax only The available tools: ```ts +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } + 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]` — 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: { @@ -53,14 +55,14 @@ declare const tools: { 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. */ justification?: string; - }): Promise; + } & Record): Promise; /** 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: { /** The concrete completion objective inferred from the direct human request. */ objective: string; /** Optional positive safe-integer limit on automatic continuation rounds. */ max_goal_rounds?: number; - }): Promise; + } & Record): Promise; /** Edit an existing UTF-8 text file by replacing literal text. */ edit(args: { /** Path to edit, resolved by the filesystem backend. */ @@ -75,16 +77,16 @@ declare const tools: { 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. */ justification?: string; - }): Promise; + } & Record): Promise; /** 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): Promise; + get_goal(args: Record): Promise; /** 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: { /** The immutable completion objective for every fresh Ralph round. */ objective: string; /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */ maxRounds?: number; - }): Promise; + } & Record): Promise; /** Read a UTF-8 text file and return line-numbered content. */ read(args: { /** Path to read, resolved by the filesystem backend. */ @@ -93,12 +95,12 @@ declare const tools: { offset?: number; /** Maximum number of lines to return. Defaults to 2000. */ limit?: number; - }): Promise; + } & Record): Promise; /** 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: { /** The exact skill name from the available skills list. */ name: string; - }): Promise; + } & Record): Promise; /** 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: { /** A short (3-5 word) description of the delegated task, for display. */ @@ -107,7 +109,7 @@ declare const tools: { prompt: string; /** Run as a background task and return its id; collect with task_output or stop with task_kill. */ run_in_background?: boolean; - }): Promise; + } & Record): Promise; /** 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: { /** A short (3-5 word) description of the delegated task, for display. */ @@ -116,16 +118,16 @@ declare const tools: { prompt: string; /** Run as a background task and return its id; collect with task_output or stop with task_kill. */ run_in_background?: boolean; - }): Promise; + } & Record): Promise; /** 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 id returned by the tool that started the background work. */ task_id: string; /** Optional short reason, recorded in the log and forwarded to the task. */ reason?: string; - }): Promise; + } & Record): Promise; /** List your background tasks (running and finished) with their ids, kinds, and statuses. */ - task_list(args: Record): Promise; + task_list(args: Record): Promise; /** 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 id returned by the tool that started the background work. */ @@ -134,7 +136,7 @@ declare const tools: { wait?: boolean; /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */ timeout_ms?: number; - }): Promise; + } & Record): Promise; /** 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: { /** The COMPLETE task list, replacing any previous list. */ @@ -143,8 +145,8 @@ declare const tools: { content: string; /** pending (not started) | in_progress (now) | completed (done). */ status: "pending" | "in_progress" | "completed"; - })[]; - }): Promise; + } & Record)[]; + } & Record): Promise; /** 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: { /** Exact id returned by get_goal. */ @@ -159,7 +161,7 @@ declare const tools: { max_goal_rounds?: number; /** Concrete blocking condition; required only with action blocked. */ blocked_reason?: string; - }): Promise; + } & Record): Promise; /** 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 ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — 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` — 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` — 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: { /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */ @@ -173,7 +175,7 @@ declare const tools: { /** Optional guidance on when this workflow applies. */ whenToUse?: string; /** Optional phase declarations matched by phase() calls. */ - phases?: { + phases?: ({ /** The phase title phase() calls match by exact string. */ title: string; /** Optional one-line description of the phase. */ @@ -182,11 +184,11 @@ declare const tools: { provider?: string; /** Optional model override this phase is expected to use. */ model?: string; - }[]; - }; + } & Record)[]; + } & Record; /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */ - args?: Record; - }): Promise; + args?: Record; + } & Record): Promise; /** Create or fully replace a UTF-8 text file. */ write(args: { /** Path to write, resolved by the filesystem backend. */ @@ -197,6 +199,6 @@ declare const tools: { 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. */ justification?: string; - }): Promise; + } & Record): Promise; } ``` diff --git a/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json index 1bfe74b704..4ee311eb65 100644 --- a/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json @@ -288,6 +288,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -373,6 +374,7 @@ "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -391,6 +393,7 @@ "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -422,7 +425,8 @@ }, "args": { "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": [ @@ -755,6 +759,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -840,6 +845,7 @@ "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -858,6 +864,7 @@ "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -889,7 +896,8 @@ }, "args": { "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": [ diff --git a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json index 1bfe74b704..4ee311eb65 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json @@ -288,6 +288,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -373,6 +374,7 @@ "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -391,6 +393,7 @@ "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -422,7 +425,8 @@ }, "args": { "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": [ @@ -755,6 +759,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -840,6 +845,7 @@ "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -858,6 +864,7 @@ "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -889,7 +896,8 @@ }, "args": { "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": [ diff --git a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json index 52b3c1812e..de3254fd2e 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json @@ -288,6 +288,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -373,6 +374,7 @@ "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -391,6 +393,7 @@ "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -422,7 +425,8 @@ }, "args": { "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": [ diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json index 52b3c1812e..de3254fd2e 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json @@ -288,6 +288,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -373,6 +374,7 @@ "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -391,6 +393,7 @@ "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -422,7 +425,8 @@ }, "args": { "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": [ diff --git a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json index 52b3c1812e..de3254fd2e 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json @@ -288,6 +288,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -373,6 +374,7 @@ "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -391,6 +393,7 @@ "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -422,7 +425,8 @@ }, "args": { "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": [ diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 0feec1729a..6098056fb9 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -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 { command: string description: string diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 04c18264b1..b2a78ff9fe 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -287,7 +287,7 @@ describe('bash tool', () => { }) // Type and required-key violations are rejected by the harness - // (defineTool validates against the SchemaSpec — the arg-validation Agent Note) before execute. + // (defineTool validates against the ParameterSchemaSpec — the arg-validation Agent Note) before execute. it.each([ [{}, /missing required property "command"/], [{ command: 42, description: 'd' }, /"command" must be a string/], @@ -303,7 +303,7 @@ describe('bash tool', () => { expect(text(result)).toMatch(pattern) }) - // Value constraints the SchemaSpec can't express stay in the tool body. + // Value constraints the ParameterSchemaSpec can't express stay in the tool body. it.each([ [{ command: ' ', description: 'd' }, /invalid command/], [{ command: 'x', description: ' ' }, /invalid description/], diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 20ea51c087..23ffa88923 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1336,6 +1336,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'InjectOptions', 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;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n}', + }, + { + name: 'JsonSchemaScalar', + declaration: 'export type JsonSchemaScalar = string | number | boolean | null;', + }, + { + name: 'JsonSchemaType', + declaration: 'export type JsonSchemaType = \'object\' | \'array\' | \'string\' | \'number\' | \'integer\' | \'boolean\' | \'null\';', + }, { name: 'JsonValue', declaration: 'export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n};', @@ -1368,6 +1380,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'MessageSourceMap', declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n plugin: {\n kind: \'plugin\';\n plugin: string;\n };\n}', }, + { + name: 'ObjectJsonSchema', + declaration: 'export type ObjectJsonSchema = JsonSchemaNode & {\n type: \'object\';\n};', + }, { name: 'PresetOption', 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', 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;\n required?: string[];\n additionalProperties?: boolean;\n items?: StructuredSchemaNode;\n enum?: StructuredScalar[];\n const?: StructuredScalar;\n description?: string;\n title?: string;\n default?: unknown;\n examples?: unknown;\n}', - }, - { - name: 'StructuredSchemaType', - declaration: 'export type StructuredSchemaType = \'object\' | \'array\' | \'string\' | \'number\' | \'integer\' | \'boolean\' | \'null\';', - }, { name: 'SubagentCapabilities', declaration: 'export interface SubagentCapabilities {\n readonly outputSchema: boolean;\n readonly depthLimit: boolean;\n readonly toolFilter: boolean;\n readonly persona: boolean;\n}', @@ -1578,7 +1578,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentStartRequest', - declaration: 'export interface SubagentStartRequest {\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly agentOptions?: AgentOptions;\n readonly outputSchema?: StructuredOutputSchema;\n readonly maxDepth?: number;\n readonly toolFilter?: ToolRestriction;\n readonly persona?: string;\n}', + declaration: 'export interface SubagentStartRequest {\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly agentOptions?: AgentOptions;\n readonly outputSchema?: ObjectJsonSchema;\n readonly maxDepth?: number;\n readonly toolFilter?: ToolRestriction;\n readonly persona?: string;\n}', }, { name: 'SubagentStopReason', diff --git a/packages/cordis/tool-cordis/src/guard.ts b/packages/cordis/tool-cordis/src/guard.ts index 2198533376..c55fd34b78 100644 --- a/packages/cordis/tool-cordis/src/guard.ts +++ b/packages/cordis/tool-cordis/src/guard.ts @@ -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` / * `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 @@ -15,12 +15,13 @@ import { Context } from 'cordis' import type { Plugin } from 'cordis' 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' const DYNAMIC_TOOL = Symbol('tool-cordis.dynamic-tool') -const SCHEMA_TYPES = new Set(['string', 'number', 'boolean', 'object', 'array']) -const VALID_TYPES = '\'string\' | \'number\' | \'boolean\' | \'object\' | \'array\'' +const SCHEMA_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'null', 'object', 'array', 'json']) +const VALID_TYPES = '\'string\' | \'number\' | \'integer\' | \'boolean\' | \'null\' | \'object\' | \'array\' | \'json\'' +const ANNOTATION_KEYS = ['description', 'title', 'default', 'examples'] as const type DynamicToolDefinition = ToolDefinition & { [DYNAMIC_TOOL]: true } type DynamicToolMarker = { [DYNAMIC_TOOL]?: unknown } @@ -29,70 +30,196 @@ function isPlainRecord(value: unknown): value is Record { 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()): 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 = {} + 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, output: Record, 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, 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 - * SchemaSpec. Accepts the DSL directly, or the JSON-Schema-style - * `{ type: 'object', properties, required: […] }` wrapper models write by - * prior — the wrapper unwraps and its `required` array becomes per-property - * flags (see the module doc). + * ParameterSchemaSpec. A raw JSON-Schema object wrapper retains its open root + * default, while the direct DSL is already an implicit open property map. */ -function normalizeSchemaSpec(value: unknown, path = 'parameters'): Record { +function normalizeParameterSchemaSpec(value: unknown, path = 'parameters'): { + spec: Record + rootAnnotations?: Record +} { if (!isPlainRecord(value)) { - throw new Error(`harness.defineTool ${path} must be a SchemaSpec object`) + throw new Error(`harness.defineTool ${path} must be a ParameterSchemaSpec object`) } - let entries = value - const requiredNames = new Set() - if (value.type === 'object' && isPlainRecord(value.properties)) { - if (Array.isArray(value.required)) { - for (const name of value.required) requiredNames.add(name) + if (value.type === 'object') { + assertSchemaKeys(value, path, ['type', 'properties', 'required', 'additionalProperties', ...ANNOTATION_KEYS]) + if (!isPlainRecord(value.properties)) { + throw new Error(`harness.defineTool ${path}.properties must be an object of schemas`) + } + if (Object.hasOwn(value, 'additionalProperties') && value.additionalProperties !== true) { + throw new Error(`harness.defineTool ${path}.additionalProperties must be true or omitted because the implicit parameter root is open`) + } + if (Object.hasOwn(value, 'required') && value.required === undefined) { + throw new Error(`harness.defineTool ${path}.required must be an array of declared property names`) + } + const required = normalizeRequiredNames(value.required, value.properties, `${path}.required`) + const rootAnnotations: Record = {} + 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, path: string): Set { + 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, + path: string, + requiredNames: ReadonlySet, + raw: boolean, +): Record { const spec: Record = {} 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 } -/** Normalize one property: `integer` → `number`, `required: false` → absent, nested wrappers unwrapped recursively. */ -function normalizeSchemaProp(value: unknown, path: string, forceRequired = false): Record { +/** Normalize one property or nested value schema into the host realm. */ +function normalizeValueSchema( + value: unknown, + path: string, + forceRequired = false, + raw = false, + parameterProperty = false, +): Record { 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 - if (!SCHEMA_TYPES.has(type)) { + const requiredKey = parameterProperty && !raw ? ['required'] : [] + 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 = {} + 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)})`) } - // On an object property a JSON-Schema-style `required` ARRAY names required - // children (handled by the nested unwrap below); everywhere else `required` - // must be a boolean, and `false` means optional. - const nestedRequiredArray = type === 'object' && Array.isArray(value.required) - if (value.required !== undefined && typeof value.required !== 'boolean' && !nestedRequiredArray) { - throw new Error(`harness.defineTool ${path}.required must be a boolean when present`) - } - const prop: Record = { type } - if (forceRequired || value.required === true) prop.required = true - if (typeof value.description === 'string') prop.description = value.description - if (Array.isArray(value.enum)) prop.enum = [...value.enum as unknown[]] - if (value.default !== undefined) prop.default = value.default - if (value.properties !== undefined) { - if (type !== 'object') { - throw new Error(`harness.defineTool ${path}.properties is only valid for type "object"`) + const type = value.type + prop.type = type + + switch (type) { + case 'object': { + assertSchemaKeys(value, path, ['type', 'properties', 'additionalProperties', ...requiredKey, ...(raw ? ['required'] : []), ...ANNOTATION_KEYS]) + if (!raw && (!Object.hasOwn(value, 'additionalProperties') || typeof value.additionalProperties !== 'boolean')) { + throw new Error(`harness.defineTool ${path}.additionalProperties must be explicitly true or false`) + } + if (raw && Object.hasOwn(value, 'additionalProperties') && typeof value.additionalProperties !== 'boolean') { + throw new Error(`harness.defineTool ${path}.additionalProperties must be a boolean`) + } + if (raw && Object.hasOwn(value, 'required') && value.required === undefined) { + throw new Error(`harness.defineTool ${path}.required must be an array of declared property names`) + } + prop.additionalProperties = 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() + 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. - prop.properties = normalizeSchemaSpec( - { type: 'object', properties: value.properties, required: value.required }, - `${path}.properties`, - ) + case 'array': + assertSchemaKeys(value, path, ['type', 'items', ...requiredKey, ...ANNOTATION_KEYS]) + if (Object.hasOwn(value, 'items')) prop.items = normalizeValueSchema(value.items, `${path}.items`, false, raw) + 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 { @@ -160,19 +287,22 @@ function assertExecuteReturn(value: unknown): ToolExecuteReturn { /** * The `harness.defineTool` handed into the sandbox: the real DSL, with `parameters` normalized - * into a fresh host-realm SchemaSpec (JSON-Schema wrapper unwrapped, `integer` mapped, - * `required: false` dropped) and the tool's `execute` return normalized into the host realm + * into a fresh host-realm ParameterSchemaSpec (raw object wrappers unwrapped, + * required arrays mapped, and explicit DSL object openness enforced) and the tool's `execute` return normalized into the host realm * via a JSON round-trip. Non-JSON or wrong-shape output fails that call instead of poisoning * the session log. - * @param options - the standard `defineTool` options; `parameters` may be the SchemaSpec DSL or a JSON-Schema-style wrapper. + * @param options - the standard `defineTool` options; `parameters` may be the ParameterSchemaSpec DSL or a JSON-Schema-style wrapper. * @returns the marker-tagged definition `harness.registerTool` (and the guarded `ctx.tools.register`) accepts. */ export function sandboxDefineTool(options: Parameters[0]): ToolDefinition { - const parameters = normalizeSchemaSpec((options as { parameters?: unknown }).parameters) - const tool = defineTool({ ...options, parameters } as Parameters[0]) + const normalized = normalizeParameterSchemaSpec((options as { parameters?: unknown }).parameters) + const tool = defineTool({ ...options, parameters: normalized.spec } as Parameters[0]) + const parameters = { ...tool.parameters, ...normalized.rootAnnotations } + assertSupportedJsonSchema(parameters) const execute = tool.execute.bind(tool) return markDynamicTool({ ...tool, + parameters, async execute(args, exec) { // JSON.stringify yields NO JSON for an undefined (or function/symbol) // return despite its string-typed signature — route that into diff --git a/packages/cordis/tool-cordis/src/index.ts b/packages/cordis/tool-cordis/src/index.ts index 4fc5bd4328..9ac29aeb07 100644 --- a/packages/cordis/tool-cordis/src/index.ts +++ b/packages/cordis/tool-cordis/src/index.ts @@ -121,9 +121,9 @@ export function apply(ctx: Context, config: Config): void { + '`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 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 ' diff --git a/packages/cordis/tool-cordis/tests/mount.spec.ts b/packages/cordis/tool-cordis/tests/mount.spec.ts index 2ddee8dbca..42af6fc64e 100644 --- a/packages/cordis/tool-cordis/tests/mount.spec.ts +++ b/packages/cordis/tool-cordis/tests/mount.spec.ts @@ -156,11 +156,14 @@ describe('cordis_mount', () => { description: 'written in the JSON-Schema dialect', parameters: { type: 'object', + title: 'Raw parameters', + default: { text: 'default' }, + examples: [{ text: 'example' }], properties: { text: { type: 'string', description: 'the text' }, count: { type: 'integer', default: 1 }, mode: { type: 'string', enum: ['fast', 'slow'] }, - extra: { type: 'string', required: false }, + extra: { type: 'string' }, }, required: ['text'], }, @@ -173,14 +176,19 @@ describe('cordis_mount', () => { expect(result.isError).toBe(false) // The registered schema is canonical JSON Schema derived from the DSL: - // the required array survived, integer became number, extra is optional. + // the required array survived, integer stayed integer, extra is optional. const schema = ctx.tools.schemas().find(s => s.name === 'json_schema_tool')! const parameters = schema.parameters as { properties: Record required?: string[] } expect(parameters.required).toEqual(['text']) - expect(parameters.properties.count!.type).toBe('number') + expect(parameters).toMatchObject({ + title: 'Raw parameters', + default: { text: 'default' }, + examples: [{ text: 'example' }], + }) + expect(parameters.properties.count!.type).toBe('integer') expect(parameters.properties.count!.default).toBe(1) expect(parameters.properties.mode!.enum).toEqual(['fast', 'slow']) // Arg validation enforces the normalized spec: text required, extra not. @@ -202,7 +210,10 @@ describe('cordis_mount', () => { name: 'nested_json_schema_tool', description: 'nested dialect', parameters: { - cfg: { type: 'object', properties: { label: { type: 'string' } }, required: ['label'] }, + type: 'object', + properties: { + cfg: { type: 'object', properties: { label: { type: 'string' } }, required: ['label'] }, + }, }, 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') }) + 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([ - ['parameters: 42', 'must be a SchemaSpec object'], - ['parameters: { text: 42 }', 'parameters.text must be a SchemaSpec property object'], - ['parameters: { text: { type: \'str\' } }', 'parameters.text must declare a valid type: \'string\' | \'number\' | \'boolean\' | \'object\' | \'array\' (got "str")'], - ['parameters: { text: { type: \'string\', required: \'yes\' } }', 'parameters.text.required must be a boolean when present'], - ['parameters: { text: { type: \'string\', properties: {} } }', 'parameters.text.properties is only valid for type "object"'], - ['parameters: { text: { type: \'string\', items: { type: \'string\' } } }', 'parameters.text.items is only valid for type "array"'], - ])('rejects a malformed SchemaSpec (%s) with a teaching error', async (parameters, message) => { + ['parameters: 42', 'must be a ParameterSchemaSpec object'], + ['parameters: { text: 42 }', 'parameters.text must be a ParameterSchemaSpec property object'], + ['parameters: { text: { type: \'str\' } }', 'parameters.text must declare a valid type: \'string\' | \'number\' | \'integer\' | \'boolean\' | \'null\' | \'object\' | \'array\' | \'json\' (got "str")'], + ['parameters: { text: { type: \'string\', required: \'yes\' } }', 'parameters.text.required must be true when present'], + ['parameters: { text: { type: \'string\', properties: {} } }', 'parameters.text.properties is not supported by the unified schema DSL'], + ['parameters: { text: { type: \'string\', items: { type: \'string\' } } }', 'parameters.text.items is not supported by the unified schema DSL'], + ['parameters: { text: { type: \'object\', properties: {} } }', 'parameters.text.additionalProperties must be explicitly true or false'], + ['parameters: { text: { type: \'object\', additionalProperties: \'no\' } }', 'parameters.text.additionalProperties must be explicitly true or false'], + ['parameters: { type: \'object\' }', 'parameters.properties must be an object of schemas'], + ['parameters: { type: \'object\', properties: {}, additionalProperties: false }', 'parameters.additionalProperties must be true or omitted'], + ['parameters: { type: \'object\', properties: {}, required: \'text\' }', 'parameters.required must be an array of declared property names'], + ['parameters: { type: \'object\', properties: {}, required: undefined }', 'parameters.required must be an array of declared property names'], + ['parameters: { type: \'object\', properties: {}, required: [\'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 result = await call(ctx, 'cordis_mount', { code: ` @@ -246,7 +366,7 @@ describe('cordis_mount', () => { 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 result = await call(ctx, 'cordis_mount', { code: ` @@ -258,7 +378,7 @@ describe('cordis_mount', () => { name: 'nested_schema_tool', description: 'nested', parameters: { - item: { type: 'object', required: true, properties: { label: { type: 'string', required: true } } }, + item: { type: 'object', additionalProperties: true, required: true, properties: { label: { type: 'string', required: true } } }, tags: { type: 'array', items: { type: 'string' } }, }, async execute(args) { return [{ type: 'text', text: args.item.label }] }, diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 2cb841d0e2..5ec2d4e7e4 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -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. -See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, and `schemaSpecToJsonSchema` in the public API for details. +See `defineTool`, `validateArgs`, `ToolArgsError`, `ValueSchemaSpec`, `ParameterSchemaSpec`, `InferValue`, `InferArgs`, `valueSchemaSpecToJsonSchema`, and `parameterSchemaSpecToJsonSchema` in the public API for details. Optional `timeoutMs` must be positive and finite; it is policy metadata, not model-visible schema. Optional `isConcurrencySafe(args)` receives typed, softly validated arguments. Exact `true` permits concurrent dispatch/body execution; invalid input and all other outcomes remain exclusive. Opted-in bodies do not mutate parent-owned state, and shared-state races must commute or fail closed. The [parallel tool-call Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md) owns the full safety contract. -### Structured-output schema subset +### Enforced raw JSON Schema subset -`StructuredOutputSchema` is the object-rooted raw JSON Schema subset used by subagents and workflows for machine-readable results. It accepts one scalar `type`, object `properties`/`required`/boolean `additionalProperties`, array `items`, and scalar `enum`/`const`. The annotations `description`, `title`, `default`, and `examples` are ignored but must remain JSON data. Type arrays, undeclared required keys, and unsupported keywords fail through `OutputSchemaError` rather than being ignored; `validateStructuredValue()` returns path-qualified violations without throwing. +`JsonSchemaNode` is the raw counterpart shared by tool outputs, Code Mode generation, subagents, and workflows. It permits any JSON root, an annotation-only unconstrained JSON node, and exact-one `oneOf`; annotations must remain lossless JSON. `assertSupportedJsonSchema()` rejects unsupported constructs, while `validateJsonSchemaValue()` returns path-qualified violations. Subagents and workflows retain their caller-defined object-root requirement through `assertObjectJsonSchema()` and `ObjectJsonSchema`, not through a limitation in the shared vocabulary. ### Tool-owned UI presentation diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 18fa286a16..2c01639047 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -23,27 +23,42 @@ import { renderToolsSdk } from './ts-types.ts' export { defineTool, - schemaSpecToJsonSchema, + valueSchemaSpecToJsonSchema, + parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, - type SchemaSpec, - type SchemaProp, - type SchemaType, + type ValueSchemaAnnotations, + type StringValueSchemaSpec, + type NumberValueSchemaSpec, + type IntegerValueSchemaSpec, + type BooleanValueSchemaSpec, + type NullValueSchemaSpec, + type ArrayValueSchemaSpec, + type ObjectValueSchemaSpec, + type JsonValueSchemaSpec, + type OneOfValueSchemaSpec, + type ValueSchemaSpec, + type ParameterPropertySpec, + type ParameterSchemaSpec, + type ParameterJsonSchema, + type InferValue, type InferArgs, type DefineToolOptions, - type JsonSchemaObject, } from './schema.ts' export { - assertSupportedOutputSchema, - validateStructuredValue, - OutputSchemaError, - type StructuredOutputSchema, - type StructuredSchemaNode, - type StructuredSchemaType, - type StructuredScalar, + assertSupportedJsonSchema, + assertObjectJsonSchema, + validateJsonSchemaValue, + JsonSchemaError, + type JsonSchemaNode, + type ObjectJsonSchema, + type JsonSchemaType, + type JsonSchemaScalar, } from './json-schema.ts' +export type { JsonValue } from '@deepseek-ai/dsh-session' + export { CodeRunFailedError, RUN_CODE_NAME } from './code-mode.ts' export { jsonSchemaToTs, renderToolsSdk } from './ts-types.ts' diff --git a/packages/core/tools/src/json-schema.ts b/packages/core/tools/src/json-schema.ts index e1a0dc43a6..82ee0c50ac 100644 --- a/packages/core/tools/src/json-schema.ts +++ b/packages/core/tools/src/json-schema.ts @@ -1,122 +1,124 @@ /** - * Structured-output JSON Schema subset for subagents and workflows. It supports - * one scalar `type`; object `properties`/`required`/boolean - * `additionalProperties`; array `items`; scalar `enum`/`const`; and JSON-valued - * annotations. Unsupported or misplaced keywords reject rather than being - * accepted without enforcement, and structured-output roots must be objects. + * Enforced JSON Schema subset shared by tool outputs, generated Code Mode + * types, subagents, and workflows. The subset accepts any JSON root, an + * annotation-only schema for unconstrained JSON, one scalar `type`, object + * `properties`/`required`/boolean `additionalProperties`, array `items`, + * type-correct scalar `enum`/`const`, and exact-one `oneOf`. + * + * Unsupported or misplaced keywords reject rather than being accepted without + * enforcement. Consumers that require an object root apply + * {@link assertObjectJsonSchema} at their own boundary. * @module dsh-tools/json-schema */ import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm' +import { isJsonValue, type JsonValue } from '@deepseek-ai/dsh-session' -/** The scalar values `enum`/`const` may carry (finite numbers only). */ -export type StructuredScalar = string | number | boolean | null +/** Scalar JSON values supported by `enum` and `const`. */ +export type JsonSchemaScalar = string | number | boolean | null -/** The `type` keywords the subset accepts. */ -export type StructuredSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null' +/** Single-type keywords accepted by the enforced subset. */ +export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null' + +/** Scalar-only schema types accepted by literal constraints. */ +type JsonSchemaScalarType = Exclude /** - * One node of the structured-output schema subset. Recursive via `properties` - * and `items`; see the module doc for the exact keyword semantics. + * One raw JSON Schema node in the enforced subset. The optional fields express + * the external wire shape; {@link assertSupportedJsonSchema} rejects invalid + * combinations before a caller treats the node as trusted. */ -export interface StructuredSchemaNode { - type: StructuredSchemaType +export interface JsonSchemaNode { + /** Omit with no constraints for any JSON value, or use `oneOf`. */ + type?: JsonSchemaType + /** Exactly one branch must validate; at least two branches are required. */ + oneOf?: JsonSchemaNode[] /** Nested property schemas (`type: 'object'` only). */ - properties?: Record + properties?: Record /** Required property names; each must appear in `properties`. */ required?: string[] - /** `false` rejects undeclared keys; absent/`true` allows them (JSON Schema default). */ + /** `false` rejects undeclared keys; absent/`true` follows JSON Schema's open default. */ additionalProperties?: boolean - /** Item schema (`type: 'array'` only); absent ⇒ any JSON items. */ - items?: StructuredSchemaNode - /** Allowed values (scalar types only). */ - enum?: StructuredScalar[] - /** The single allowed value (scalar types only). */ - const?: StructuredScalar + /** Item schema (`type: 'array'` only); absent accepts any JSON item. */ + items?: JsonSchemaNode + /** Allowed values for a scalar node. */ + enum?: JsonSchemaScalar[] + /** The single allowed value for a scalar node. */ + const?: JsonSchemaScalar /** Annotation, ignored for validation. */ description?: string /** Annotation, ignored for validation. */ title?: string - /** Annotation, ignored for validation (must still be JSON data). */ - default?: unknown - /** Annotation, ignored for validation (must still be JSON data). */ - examples?: unknown + /** Annotation, ignored for validation but required to be lossless JSON. */ + default?: JsonValue + /** Annotation, ignored for validation but required to be lossless JSON. */ + examples?: JsonValue } -/** A structured-output schema: an OBJECT-rooted {@link StructuredSchemaNode}. */ -export type StructuredOutputSchema = StructuredSchemaNode & { type: 'object' } +/** A consumer-constrained object-rooted schema. */ +export type ObjectJsonSchema = JsonSchemaNode & { type: 'object' } /** - * Thrown by {@link assertSupportedOutputSchema} when a schema falls outside the - * supported subset. Extends {@link HarnessError} (`code: 'UNSUPPORTED_SCHEMA'`) - * so seam code and tool results can route on it; `violations` lists every - * offending path, not just the first. + * Thrown when a raw schema falls outside the enforced subset. `violations` + * lists every offending path instead of stopping at the first author error. */ -export class OutputSchemaError extends HarnessError { - /** The individual violation messages, in walk order. */ +export class JsonSchemaError extends HarnessError { + /** Individual schema violations in walk order. */ readonly violations: string[] constructor(violations: string[]) { - super(`unsupported output schema: ${violations.join('; ')}`, 'UNSUPPORTED_SCHEMA') - this.name = 'OutputSchemaError' + super(`unsupported JSON schema: ${violations.join('; ')}`, 'UNSUPPORTED_SCHEMA') + this.name = 'JsonSchemaError' this.violations = violations } } -/** The keywords the subset accepts, checked (`constraint`) or ignored (`annotation`). */ -const CONSTRAINT_KEYWORDS = new Set(['type', 'properties', 'required', 'additionalProperties', 'items', 'enum', 'const']) +const CONSTRAINT_KEYWORDS = new Set([ + 'type', + 'oneOf', + 'properties', + 'required', + 'additionalProperties', + 'items', + 'enum', + 'const', +]) const ANNOTATION_KEYWORDS = new Set(['description', 'title', 'default', 'examples']) - -const SCHEMA_TYPES: readonly StructuredSchemaType[] = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'] +const SCHEMA_TYPES: readonly JsonSchemaType[] = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'] /** - * Whether a value is a PLAIN JSON object — non-null, non-array, and with a - * prototype chain of at most one link (`null`-proto, or any realm's - * `Object.prototype`, whose own prototype is `null`). Realm-agnostic on - * purpose: a schema materialized in another realm carries THAT realm's - * `Object.prototype`, which an identity check would wrongly reject. Exotic - * hosts (`Date`, `Map`, class instances) have longer chains and are rejected — - * they would serialize lossily (`Date` → string, `Map` → `{}`) instead of - * failing loud. + * Test for a realm-agnostic plain JSON record without accepting arrays or + * exotic objects. + * @param value - candidate record from any JavaScript realm. + * @returns Whether the value has a plain-object prototype chain. */ -function isObjectLike(value: unknown): value is Record { +export function isPlainJsonRecord(value: unknown): value is Record { if (typeof value !== 'object' || value === null || Array.isArray(value)) return false const proto: unknown = Object.getPrototypeOf(value) return proto === null || Object.getPrototypeOf(proto) === null } -/** Whether a value is a supported scalar (`enum`/`const` member): string, finite number, boolean, or null. */ -function isStructuredScalar(value: unknown): value is StructuredScalar { - return value === null || typeof value === 'string' || typeof value === 'boolean' - || (typeof value === 'number' && Number.isFinite(value)) +/** Lossless finite JSON number, excluding negative zero. */ +function isJsonNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0) } -/** - * Whether a value is JSON data (annotation payloads only): scalars, arrays, and - * object-likes of such values. Realm-agnostic on purpose (no prototype check) — - * the schema may have been materialized from another realm; structural JSON-ness - * is what the wire needs. Cycles are rejected via `seen`. - */ -function isJsonData(value: unknown, seen: Set): boolean { - if (isStructuredScalar(value)) return true - // The scalar check above already returned for null, so `object` here is a real object. - if (typeof value !== 'object') return false - if (seen.has(value)) return false - seen.add(value) - 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) +/** Whether a scalar is valid for one declared schema type. */ +function scalarMatches(type: JsonSchemaScalarType, value: unknown): value is JsonSchemaScalar { + switch (type) { + case 'string': return typeof value === 'string' + case 'number': return isJsonNumber(value) + case 'integer': return isJsonNumber(value) && Number.isInteger(value) + case 'boolean': return typeof value === 'boolean' + case 'null': return value === null + /* v8 ignore next -- JsonSchemaScalarType is closed; this retains compile-time exhaustiveness. */ + default: return assertNever(type, 'JsonSchemaType') } } -/** 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): void { - if (!isObjectLike(node)) { + if (!isPlainJsonRecord(node)) { violations.push(`${path} must be a schema object`) return } @@ -125,199 +127,258 @@ function checkSchemaNode(node: unknown, path: string, violations: string[], seen return } seen.add(node) - - for (const key of Object.keys(node)) { - if (CONSTRAINT_KEYWORDS.has(key)) continue - if (ANNOTATION_KEYWORDS.has(key)) { - if (!isJsonData(node[key], new Set())) violations.push(`${path}.${key} annotation must be JSON data`) - continue + try { + for (const key of Object.keys(node)) { + if (CONSTRAINT_KEYWORDS.has(key)) continue + if (ANNOTATION_KEYWORDS.has(key)) { + try { + if (!isJsonValue(node[key])) violations.push(`${path}.${key} annotation must be lossless JSON data`) + } catch { + violations.push(`${path}.${key} annotation must be lossless JSON data`) + } + continue + } + violations.push(`${path}.${key} is not a supported keyword (subset: type/oneOf/properties/required/additionalProperties/items/enum/const + annotations)`) + } + if (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 - 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('/')}`) + const hasType = Object.hasOwn(node, 'type') + const hasOneOf = Object.hasOwn(node, 'oneOf') + if (hasType && hasOneOf) { + violations.push(`${path} cannot declare both type and oneOf`) + 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 = { + 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) - 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 = { - 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 - * and entirely within the enforced subset. Throws {@link OutputSchemaError} - * (`UNSUPPORTED_SCHEMA`) listing EVERY violation; returns (and narrows) on - * success. Call this at the seam boundary, before any child is created. - * @param schema - the caller-supplied schema (unknown until asserted). - * @returns nothing — the assertion signature narrows `schema` to - * {@link StructuredOutputSchema} in the caller's scope on normal return. + * Assert that an arbitrary raw schema uses only the enforced subset. + * Annotation-only schemas are accepted as the standard unconstrained-JSON + * form; callers that require an object root use {@link assertObjectJsonSchema}. + * @param schema - untrusted raw JSON Schema. + * @returns Assertion that the schema belongs to the supported subset. */ -export function assertSupportedOutputSchema(schema: unknown): asserts schema is StructuredOutputSchema { +export function assertSupportedJsonSchema(schema: unknown): asserts schema is JsonSchemaNode { const violations: string[] = [] checkSchemaNode(schema, 'schema', violations, new Set()) - if (violations.length === 0 && (schema as StructuredSchemaNode).type !== 'object') { - violations.push('schema.type must be "object" (structured output is object-rooted)') - } - if (violations.length > 0) throw new OutputSchemaError(violations) + if (violations.length > 0) throw new JsonSchemaError(violations) } -/** Collect violations for one value against an (already asserted) schema node. */ -function checkValue(node: StructuredSchemaNode, value: unknown, path: string): string[] { +/** + * 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) { 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 properties = node.properties ?? {} - // Own-property discipline throughout: JSON carries own enumerable - // properties only, so an inherited `toString` must not satisfy - // `required`, dodge `additionalProperties: false`, or be validated as if - // the value carried it. for (const key of node.required ?? []) { - if (!Object.hasOwn(value, key) || value[key] === undefined) violations.push(`missing required property "${path}.${key}"`) + if (!Object.hasOwn(value, key) || value[key] === undefined) violations.push(`missing required property "${propertyPath(path, key)}"`) } for (const [key, child] of Object.entries(properties)) { if (!Object.hasOwn(value, key) || value[key] === undefined) continue - violations.push(...checkValue(child, value[key], `${path}.${key}`)) + violations.push(...checkValue(child, value[key], propertyPath(path, key))) } if (node.additionalProperties === false) { for (const key of Object.keys(value)) { - if (!Object.hasOwn(properties, key)) violations.push(`"${path}.${key}" is not a declared property (additionalProperties: false)`) + 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': { - if (!Array.isArray(value)) return [`"${path}" must be an array`] - if (!node.items) return [] + if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) return [`"${diagnosticPath(path)}" must be an array`] 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': { - if (typeof value !== 'string') return [`"${path}" must be a string`] + if (typeof value !== 'string') return [`"${diagnosticPath(path)}" must be a string`] break } 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 } 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 } case 'boolean': { - if (typeof value !== 'boolean') return [`"${path}" must be a boolean`] + if (typeof value !== 'boolean') return [`"${diagnosticPath(path)}" must be a boolean`] break } case 'null': { - if (value !== null) return [`"${path}" must be null`] + if (value !== null) return [`"${diagnosticPath(path)}" must be null`] break } - default: - return assertNever(node.type, 'validateStructuredValue') + default: return assertNever(node.type, 'JsonSchemaType') } - // Scalar constraint checks, shared by every scalar branch above. - if (node.enum && !node.enum.includes(value)) { - return [`"${path}" must be one of ${JSON.stringify(node.enum)}`] + if (node.enum !== undefined && !node.enum.includes(value)) { + return [`"${diagnosticPath(path)}" must be one of ${JSON.stringify(node.enum)}`] } - if ('const' in node && value !== node.const) { - return [`"${path}" must be ${JSON.stringify(node.const)}`] + if (Object.hasOwn(node, 'const') && value !== node.const) { + return [`"${diagnosticPath(path)}" must be ${JSON.stringify(node.const)}`] } return [] } /** - * Validate a value against an (already {@link assertSupportedOutputSchema}- - * asserted) schema. Returns human-readable, path-qualified violation messages - * — empty means valid. Total: never throws, however malformed the value. - * @param schema - the asserted schema to check against. - * @param value - the candidate value (e.g. parsed tool-call arguments). - * @returns every violation found, in walk order (empty = valid). + * Validate a candidate value against an asserted raw schema. The function is + * total for arbitrary values and returns path-qualified violations. + * @param schema - a schema accepted by {@link assertSupportedJsonSchema}. + * @param value - the candidate JSON value. + * @param path - root label used in diagnostics. + * @returns All violations in walk order; empty means valid. */ -export function validateStructuredValue(schema: StructuredOutputSchema, value: unknown): string[] { - return checkValue(schema, value, 'value') +export function validateJsonSchemaValue(schema: JsonSchemaNode, value: unknown, path = 'value'): string[] { + return checkValue(schema, value, path) } diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts index f2b62669b1..98be959e4e 100644 --- a/packages/core/tools/src/schema.ts +++ b/packages/core/tools/src/schema.ts @@ -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 { assertSupportedJsonSchema, isPlainJsonRecord, JsonSchemaError, validateJsonSchemaValue } from './json-schema.ts' +import type { JsonSchemaNode, JsonSchemaScalar, ObjectJsonSchema } from './json-schema.ts' import type { ToolCallView, ToolResultView } from './presentation.ts' -// --------------------------------------------------------------------------- -// SchemaSpec — the author-facing per-property type -// --------------------------------------------------------------------------- - -/** Valid JSON Schema primitive types for tool parameters. */ -export type SchemaType = 'string' | 'number' | 'boolean' | 'object' | 'array' - -/** One schema-spec property entry. */ -export interface SchemaProp { - type: SchemaType - /** Per-property required flag (NOT the JSON Schema top-level required array). */ - required?: true - /** Human-readable description, surfaced in the JSON Schema as well. */ +/** Annotation keywords shared by every author-facing schema node. */ +export interface ValueSchemaAnnotations { + /** Human-readable description projected into JSON Schema and generated types. */ description?: string - /** Enum of allowed values (strings only). */ - enum?: string[] - /** - * Model-visible JSON Schema default annotation. Validation does not apply it; - * dynamic tool mounts may supply it even though first-party definitions do not. - */ - default?: unknown - /** Nested properties for type: 'object'. */ - properties?: SchemaSpec - /** Items schema for type: 'array'. */ - items?: SchemaProp + /** Human-readable title projected into JSON Schema. */ + title?: string + /** Non-validating default annotation; it must be lossless JSON data. */ + default?: JsonValue + /** Non-validating examples annotation; it must be lossless JSON data. */ + examples?: JsonValue +} + +/** String value schema with type-correct literal constraints. */ +export interface StringValueSchemaSpec extends ValueSchemaAnnotations { + type: 'string' + enum?: readonly string[] + const?: string +} + +/** Finite JSON-number schema with type-correct literal constraints. */ +export interface NumberValueSchemaSpec extends ValueSchemaAnnotations { + type: 'number' + enum?: readonly number[] + const?: number +} + +/** Integer schema with type-correct literal constraints. */ +export interface IntegerValueSchemaSpec extends ValueSchemaAnnotations { + type: 'integer' + enum?: readonly number[] + const?: number +} + +/** Boolean value schema with type-correct literal constraints. */ +export interface BooleanValueSchemaSpec extends ValueSchemaAnnotations { + type: 'boolean' + enum?: readonly boolean[] + const?: boolean +} + +/** Null value schema with type-correct literal constraints. */ +export interface NullValueSchemaSpec extends ValueSchemaAnnotations { + type: 'null' + enum?: readonly null[] + const?: null +} + +/** Array value schema; omitted `items` accepts any lossless JSON item. */ +export interface ArrayValueSchemaSpec extends ValueSchemaAnnotations { + type: 'array' + items?: ValueSchemaSpec } /** - * The author-facing parameter schema: a shallow map of property name to - * {@link SchemaProp}. Required-ness is a per-property boolean (`required: - * true`), not a separate array. + * Explicit object value schema. Openness is mandatory so a nested or output + * object never acquires an accidental JSON Schema default. */ -export type SchemaSpec = Record +export interface ObjectValueSchemaSpec extends ValueSchemaAnnotations { + type: 'object' + properties?: ParameterSchemaSpec + additionalProperties: boolean +} -// --------------------------------------------------------------------------- -// InferArgs — type-level mapping from SchemaSpec to TS argument type -// --------------------------------------------------------------------------- +/** Author-only unconstrained lossless JSON node. */ +export interface JsonValueSchemaSpec extends ValueSchemaAnnotations { + type: 'json' +} -/** Map a {@link SchemaType} to its TS primitive type. */ -type TypeOf = - T extends 'string' ? string : - T extends 'number' ? number : - T extends 'boolean' ? boolean : - T extends 'object' ? Record : - T extends 'array' ? unknown[] : - never +/** Exact-one union schema; at least two branches are required. */ +export interface OneOfValueSchemaSpec extends ValueSchemaAnnotations { + oneOf: readonly [ValueSchemaSpec, ValueSchemaSpec, ...ValueSchemaSpec[]] +} + +/** One author-facing schema for any lossless JSON value root. */ +export type ValueSchemaSpec = + | StringValueSchemaSpec + | NumberValueSchemaSpec + | IntegerValueSchemaSpec + | BooleanValueSchemaSpec + | NullValueSchemaSpec + | ArrayValueSchemaSpec + | ObjectValueSchemaSpec + | JsonValueSchemaSpec + | OneOfValueSchemaSpec + +/** One implicit parameter-root property, optionally required. */ +export type ParameterPropertySpec = ValueSchemaSpec & { required?: true } + +/** + * Tool parameter schema. The map itself is an implicit open object root; + * requiredness remains a per-property `required: true` annotation. + */ +export type ParameterSchemaSpec = Record + +/** Raw JSON Schema projection of the implicit parameter object. */ +export interface ParameterJsonSchema extends ObjectJsonSchema { + properties: Record +} /** Flatten an intersection into one object type for readable hovers. */ type Simplify = { [K in keyof T]: T[K] } & {} -/** Keys of `S` whose prop is marked `required: true`. */ -type RequiredKeys = - { [K in keyof S]: S[K] extends { required: true } ? K : never }[keyof S] +/** Keys of a property map marked `required: true`. */ +type RequiredKeys = { + [K in keyof S]: S[K] extends { required: true } ? K : never +}[keyof S] -/** - * The VALUE type of one {@link SchemaProp} — optionality is handled at the - * key level by {@link InferArgs}, never here. - * - `properties` on 'object' → recurse into the nested SchemaSpec - * - `items` on 'array' → recurse into the item prop (arrays of objects work) - * - otherwise → the primitive for `type` - */ -type InferPropValue

= - P extends { type: 'object'; properties: infer Sub extends SchemaSpec } ? InferArgs : - P extends { type: 'array'; items: infer Item extends SchemaProp } ? InferPropValue[] : - TypeOf +/** Infer the declared value of one parameter property without key optionality. */ +type InferProperty

= P extends ValueSchemaSpec ? InferValue

: never -/** - * Infer the TS argument type for a complete {@link SchemaSpec}. - * - * Properties marked `required: true` are required keys; all others are - * genuinely optional keys (`?`), so callers may omit them entirely. - * - * Example: - * ```ts - * type Args = InferArgs<{ path: { type: 'string'; required: true }; limit: { type: 'number' } }> - * // → { path: string; limit?: number } - * ``` - */ -export type InferArgs = Simplify< - & { [K in RequiredKeys]: InferPropValue } - & { [K in Exclude>]?: InferPropValue } +/** Infer an implicit property map into required and optional object keys. */ +type InferProperties = Simplify< + & { [K in RequiredKeys]: InferProperty } + & { [K in Exclude>]?: InferProperty } > -// --------------------------------------------------------------------------- -// Runtime conversion: SchemaSpec → JSON Schema -// --------------------------------------------------------------------------- +/** Infer an explicit object node, including its declared openness. */ +type InferObject = + S extends { properties: infer P extends ParameterSchemaSpec } + ? S['additionalProperties'] extends true + ? InferProperties

& Record + : InferProperties

+ : S['additionalProperties'] extends true + ? Record + : Record + +/** Infer a scalar node's literal constraint before its broad primitive type. */ +type InferScalar = + 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. - * The per-property `required` flag is collected; the caller builds the - * top-level `required` array. + * Infer the TypeScript value accepted by an author-facing value schema. + * Output schemas may therefore infer object, array, scalar, or null roots. */ -function propToJsonSchema(prop: SchemaProp): { schema: Record; required: boolean } { - const result: Record = { type: prop.type } - if (prop.description) result.description = prop.description - if (prop.enum) result.enum = prop.enum - if (prop.default !== undefined) result.default = prop.default +export type InferValue = + S extends StringValueSchemaSpec ? InferScalar : + S extends NumberValueSchemaSpec | IntegerValueSchemaSpec ? InferScalar : + S extends BooleanValueSchemaSpec ? InferScalar : + S extends NullValueSchemaSpec ? null : + S extends ArrayValueSchemaSpec + ? S extends { items: infer I extends ValueSchemaSpec } ? InferValue[] : JsonValue[] + : S extends ObjectValueSchemaSpec ? InferObject : + S extends JsonValueSchemaSpec ? JsonValue : + S extends OneOfValueSchemaSpec ? InferValue : + never - const required = prop.required === true +/** Infer the TypeScript argument object for an implicit parameter schema. */ +export type InferArgs = InferProperties - if (prop.type === 'object' && prop.properties) { - const nested = schemaSpecToJsonSchema(prop.properties) - result.properties = nested.properties - if (nested.required && nested.required.length > 0) { - result.required = nested.required +const ANNOTATION_KEYS = ['description', 'title', 'default', 'examples'] as const + +/** Throw one author-schema violation through the shared schema error type. */ +function authorError(message: string): never { + throw new JsonSchemaError([message]) +} + +/** Copy own annotation fields for validation by the raw-schema boundary. */ +function copyAnnotations(source: Record, 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, 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, +): { properties: Record; 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 = {} + 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}. */ -export interface JsonSchemaObject { - type: 'object' - properties: Record - required?: string[] +/** Compile one author node without applying any consumer root restriction. */ +function compileValueSchema( + input: unknown, + path: string, + seen: Set, + 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'`, - * `properties`, `required` array). - * - * This is a plain function — no schemastery or other framework dependency. - * @param spec - the author-facing per-property schema to convert. - * @returns the wire-format JSON Schema; the top-level `required` array is - * omitted entirely when no property is marked required. + * Compile one author-facing value schema to the enforced raw JSON Schema + * subset. The author-only `json` node becomes an annotation-only schema. + * @param spec - schema for any JSON-value root. + * @returns The asserted raw schema projection. */ -export function schemaSpecToJsonSchema(spec: SchemaSpec): JsonSchemaObject { - const properties: Record = {} - const required: string[] = [] +export function valueSchemaSpecToJsonSchema(spec: ValueSchemaSpec): JsonSchemaNode { + const schema = compileValueSchema(spec, 'schema', new Set()) + assertSupportedJsonSchema(schema) + return schema +} - for (const [key, prop] of Object.entries(spec)) { - const { schema, required: isRequired } = propToJsonSchema(prop) - properties[key] = schema - if (isRequired) required.push(key) - } - - const result: JsonSchemaObject = { +/** + * Compile the implicit open parameter object into raw JSON Schema. + * @param spec - per-property parameter definitions. + * @returns An object-rooted raw schema with no implicit-root openness override. + */ +export function parameterSchemaSpecToJsonSchema(spec: ParameterSchemaSpec): ParameterJsonSchema { + const compiled = compilePropertyMap(spec, 'parameters', new Set()) + const schema: ParameterJsonSchema = { type: 'object', - properties, + properties: compiled.properties, + ...(compiled.required === undefined ? {} : { required: compiled.required }), } - if (required.length > 0) result.required = required - - return result + assertSupportedJsonSchema(schema) + return schema } -// --------------------------------------------------------------------------- -// 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. - */ +/** Invalid model-generated arguments for a typed tool. */ export class ToolArgsError extends HarnessError { - /** The individual violation messages, in declaration order. */ + /** Individual violations in schema-walk order. */ readonly violations: string[] constructor(violations: string[]) { @@ -177,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 { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -/** Collect violations for one property value against its {@link SchemaProp}. */ -function checkValue(prop: SchemaProp, value: unknown, path: string): string[] { - switch (prop.type) { - case 'string': { - if (typeof value !== 'string') return [`"${path}" must be a string`] - break - } - case 'number': { - if (typeof value !== 'number') return [`"${path}" must be a number`] - break - } - case 'boolean': { - if (typeof value !== 'boolean') return [`"${path}" must be a boolean`] - break - } - case 'object': { - if (!isPlainObject(value)) return [`"${path}" must be an object`] - // Mirror the converter: an object without `properties` only type-checks. - return prop.properties ? checkSpec(prop.properties, value, path) : [] - } - case 'array': { - if (!Array.isArray(value)) return [`"${path}" must be an array`] - // Mirror the converter: an array without `items` only type-checks. - if (!prop.items) return [] - const items = prop.items - return value.flatMap((el, i) => checkValue(items, el, `${path}[${i}]`)) - } - default: return assertNever(prop.type, 'validateArgs') - } - // Enum membership, checked uniformly: the converter emits `enum` for any - // type ([prop.enum]), so the validator must too. `enum` is `string[]`, so a - // non-string value can never be a member — it falls out here, consistent - // with the schema the model was given. - if (prop.enum && !(prop.enum as unknown[]).includes(value)) { - return [`"${path}" must be one of ${JSON.stringify(prop.enum)}`] - } - return [] -} - -/** Collect violations for an object value against a {@link SchemaSpec}. */ -function checkSpec(spec: SchemaSpec, value: unknown, path: string): string[] { - if (!isPlainObject(value)) return [`"${path || 'arguments'}" must be an object`] - const violations: string[] = [] - for (const [key, prop] of Object.entries(spec)) { - const propPath = path ? `${path}.${key}` : key - const v = value[key] - if (v === undefined) { - // A required key absent OR present-but-undefined is a violation; an - // optional absent key is fine. `default` is NOT applied (validation only). - if (prop.required === true) violations.push(`missing required property "${propPath}"`) - continue - } - violations.push(...checkValue(prop, v, propPath)) - } - return violations -} - /** - * Validate model-generated `args` against a {@link SchemaSpec}, returning a - * list of human-readable violation messages (empty = valid). Total — never - * throws, regardless of how malformed `args` is. - * - * Semantics mirror {@link schemaSpecToJsonSchema} exactly: the top level must - * be a non-array object; required keys come only from `required: true`; extra - * keys are allowed (no `additionalProperties: false`); `default` is not - * applied; an `object`/`array` prop without `properties`/`items` only - * type-checks; `enum` is membership (strings only). - * @param spec - the declared parameter schema to validate against. - * @param args - the model-generated arguments, however malformed. - * @returns the violation messages in declaration order; empty means valid. + * Validate model-generated arguments against an implicit parameter schema. + * @param spec - declared parameter schema. + * @param args - candidate arguments, however malformed. + * @returns Path-qualified violations; empty means valid. */ -export function validateArgs(spec: SchemaSpec, args: unknown): string[] { - return checkSpec(spec, args, '') +export function validateArgs(spec: ParameterSchemaSpec, args: unknown): string[] { + return validateJsonSchemaValue(parameterSchemaSpecToJsonSchema(spec), args, '') } -// --------------------------------------------------------------------------- -// defineTool — typed helper for first-party plugin authors -// --------------------------------------------------------------------------- - /** Options for {@link defineTool}. */ -export interface DefineToolOptions { +export interface DefineToolOptions { /** Tool name (must be unique). */ readonly name: string /** Human-readable description sent to the model. */ readonly description: string - /** - * Parameter schema using the per-property-required DSL. Converted to - * standard JSON Schema at runtime. - */ + /** Per-property parameter schema compiled to an implicit open object root. */ readonly parameters: S - /** - * Optional cooperative tool-call timeout budget in milliseconds. When given it - * must be a positive finite number; it is attached to the produced - * {@link ToolDefinition} for `@deepseek-ai/dsh-timeout-policy` to enforce and - * is never sent to the model. - */ + /** Optional positive cooperative timeout budget in milliseconds. */ readonly timeoutMs?: number /** - * Optional pure synchronous classifier for sibling overlap. It receives typed - * arguments after soft validation; invalid input returns `false` without - * invoking it. See {@link ToolDefinition.isConcurrencySafe}. + * Pure classifier for sibling overlap. * @param args - typed validated arguments. - * @returns whether this call may join a parallel group. + * @returns Whether the call may join a parallel group. */ isConcurrencySafe?(args: InferArgs): boolean /** - * Tool execution function. `args` is typed as {@link InferArgs} — zero - * casts needed. Returns either a bare {@link ContentBlock}`[]` (model-facing - * content only) or a `{ content, meta }` object to also attach a tool-private - * presentation payload (see {@link ToolExecuteReturn}). + * Execute the tool after argument validation. + * @param args - typed validated arguments. + * @param exec - execution identity, caller, cancellation, and nesting data. + * @returns Model-facing content and optional presentation metadata. */ execute(args: InferArgs, exec: ToolRunContext): Promise /** - * Optional: how to present the PENDING state of one call in a UI (an editor - * tool-call card, a CLI log line). `args` is the typed, schema-validated - * argument shape — zero casts. Pure and side-effect-free: a UI may call it - * during live streaming AND a session-log replay, so depend only on `args`. - * The tool owns its presentation so a UI never special-cases tool names. See - * {@link ToolCallView}. + * Pure pending-state presenter. + * @param args - typed validated arguments. + * @returns Tool-owned render intent, or `undefined` for the generic card. */ presentCall?(args: InferArgs): ToolCallView | undefined /** - * Optional: how to present the COMPLETED state, given the typed `args` and the - * `result`. Use it to reformat result content for a UI distinctly from the - * model-facing text (e.g. a fenced ```console block). Pure and side-effect- - * free for the same replay reason. See {@link ToolResultView}. + * Pure completed-state presenter. + * @param args - typed validated arguments. + * @param result - final model-facing tool result. + * @returns Tool-owned render intent, or `undefined` for the generic card. */ presentResult?(args: InferArgs, result: ToolResult): ToolResultView | undefined } /** - * Define a first-party tool whose execution and presentation arguments are - * inferred from its per-property schema. Raw JSON-Schema definitions remain - * valid inputs to {@link ToolRegistry.register}; this helper is authoring sugar. - * @param options - the tool's name, description, typed parameter schema, - * execute body, and optional presenters. - * @returns a registry-ready definition with strict execution validation and - * soft presenter and classifier validation for replay compatibility. + * Define a first-party tool with inferred arguments and strict execution + * validation. Replay-only presenters validate softly and fall back to generic + * rendering for obsolete logged arguments. + * @param options - typed definition and optional presenters. + * @returns A registry-ready definition. */ -export function defineTool(options: DefineToolOptions): ToolDefinition { - // Object-literal execute methods don't use `this`; the reference is safe. +export function defineTool(options: DefineToolOptions): ToolDefinition { + // Object-literal methods do not use `this`; retaining references is safe. // eslint-disable-next-line @typescript-eslint/unbound-method const userExecute = options.execute // eslint-disable-next-line @typescript-eslint/unbound-method @@ -334,41 +386,34 @@ export function defineTool(options: DefineToolOptions): if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) { throw new Error(`defineTool(${options.name}): timeoutMs must be a positive finite number`) } + const parameters = parameterSchemaSpecToJsonSchema(options.parameters) + const validate = (args: unknown): string[] => validateJsonSchemaValue(parameters, args, '') const tool: ToolDefinition = { name: options.name, description: options.description, - parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record, + parameters: parameters as unknown as Record, ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), async execute(args: unknown, exec: ToolRunContext): Promise { - // Validate the model-generated args before the typed body runs. On - // mismatch we throw ToolArgsError; the registry turns it into an - // isError result so the model can self-correct. After this guard, the - // cast to InferArgs reflects the validated shape. - const violations = validateArgs(options.parameters, args) + const violations = validate(args) if (violations.length > 0) throw new ToolArgsError(violations) return userExecute(args as InferArgs, 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) { 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) } } if (userPresentResult) { tool.presentResult = (args: unknown, result: ToolResult): ToolResultView | undefined => { - if (validateArgs(options.parameters, args).length > 0) return undefined + if (validate(args).length > 0) return undefined return userPresentResult(args as InferArgs, result) } } - // Invalid arguments fail closed without invoking the typed classifier. if (userIsConcurrencySafe) { tool.isConcurrencySafe = (args: unknown): boolean => { - if (validateArgs(options.parameters, args).length > 0) return false + if (validate(args).length > 0) return false return userIsConcurrencySafe(args as InferArgs) } } diff --git a/packages/core/tools/src/ts-types.ts b/packages/core/tools/src/ts-types.ts index e5f67d0891..39cec5665e 100644 --- a/packages/core/tools/src/ts-types.ts +++ b/packages/core/tools/src/ts-types.ts @@ -7,6 +7,8 @@ */ 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. */ 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`*\/`)} */`] } +/** 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, 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 - * subset the `defineTool` DSL emits — `object` (`properties` + `required`), - * `string` (with `enum` → a literal union), `number`, `boolean`, `array` - * (`items`) — and returns `unknown` for anything else, without throwing. + * Map one enforced JSON-Schema node to a TypeScript type literal. Supports + * every unified schema construct and returns `unknown` for malformed or + * unsupported inputs without throwing. * @param schema - the JSON-Schema node (any shape; hostile inputs degrade). * @param indent - the indentation level for nested object members. * @returns the TS type text (multi-line for objects with properties). */ export function jsonSchemaToTs(schema: unknown, indent = 0): string { - if (typeof schema !== 'object' || schema === null) return 'unknown' + try { + assertSupportedJsonSchema(schema) + } catch { + return 'unknown' + } const node = schema as Record + 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) { - case 'string': { - if (Array.isArray(node.enum) && node.enum.length > 0 && node.enum.every(value => typeof value === 'string')) { - return node.enum.map(value => JSON.stringify(value)).join(' | ') - } - return 'string' - } - case 'number': return 'number' - case 'boolean': return 'boolean' + case 'string': return renderConstrainedScalar(node, 'string') + case 'number': return renderConstrainedScalar(node, 'number') + case 'integer': return renderConstrainedScalar(node, 'integer') + case 'boolean': return renderConstrainedScalar(node, 'boolean') + case 'null': return renderConstrainedScalar(node, 'null') case 'array': { - const item = jsonSchemaToTs(node.items, indent) - // Parenthesize a union item type so `('a' | 'b')[]` parses as intended. - return item.includes('|') ? `(${item})[]` : `${item}[]` + return arrayItem(Object.hasOwn(node, 'items') ? jsonSchemaToTs(node.items, indent) : 'JsonValue') } case 'object': { const properties = node.properties - if (typeof properties !== 'object' || properties === null) return 'Record' + const open = node.additionalProperties !== false + if (properties === undefined) return open ? 'Record' : 'Record' const entries = Object.entries(properties as Record) - if (entries.length === 0) return 'Record' - const required = new Set(Array.isArray(node.required) ? node.required.filter(name => typeof name === 'string') : []) + if (entries.length === 0) return open ? 'Record' : 'Record' + const required = new Set(node.required as string[] | undefined) const lines: string[] = ['{'] for (const [name, prop] of entries) { - const description = typeof prop === 'object' && prop !== null ? (prop as Record).description : undefined + const description = (prop as Record).description lines.push(...docLines(description, indent + 1)) lines.push(`${pad(indent + 1)}${renderKey(name)}${required.has(name) ? '' : '?'}: ${jsonSchemaToTs(prop, indent + 1)};`) } lines.push(`${pad(indent)}}`) - return lines.join('\n') + const declared = lines.join('\n') + return open ? `${declared} & Record` : declared } + /* v8 ignore next -- assertSupportedJsonSchema narrowed this closed type union. */ default: return 'unknown' } } @@ -106,5 +133,6 @@ export function renderToolsSdk(schemas: ToolSchema[]): string { const declaration = members.length > 0 ? `declare const tools: {\n${members.join('\n')}\n}` : 'declare const tools: {}' - return `${SDK_INSTRUCTIONS}\n\n\`\`\`ts\n${declaration}\n\`\`\`` + const jsonValue = 'type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }' + return `${SDK_INSTRUCTIONS}\n\n\`\`\`ts\n${jsonValue}\n\n${declaration}\n\`\`\`` } diff --git a/packages/core/tools/tests/json-schema.spec.ts b/packages/core/tools/tests/json-schema.spec.ts index 6fa895288e..26124083a9 100644 --- a/packages/core/tools/tests/json-schema.spec.ts +++ b/packages/core/tools/tests/json-schema.spec.ts @@ -1,304 +1,326 @@ import { describe, expect, it } from 'vitest' import { - assertSupportedOutputSchema, - OutputSchemaError, - validateStructuredValue, - type StructuredOutputSchema, -} from '../src/json-schema.ts' + assertObjectJsonSchema, + assertSupportedJsonSchema, + JsonSchemaError, + validateJsonSchemaValue, + type JsonSchemaNode, + type ObjectJsonSchema, +} from '../src/index.ts' -/** Assert-and-narrow helper: the asserted schema, typed. */ -function asserted(schema: unknown): StructuredOutputSchema { - assertSupportedOutputSchema(schema) +function asserted(schema: unknown): JsonSchemaNode { + assertSupportedJsonSchema(schema) return schema } -/** The violations OutputSchemaError carries for a bad schema (throws if it passes). */ -function violationsOf(schema: unknown): string[] { - try { - assertSupportedOutputSchema(schema) - } catch (error: unknown) { - if (error instanceof OutputSchemaError) return error.violations - throw error - } - throw new Error('expected the schema to be rejected') +function assertedObject(schema: unknown): ObjectJsonSchema { + assertObjectJsonSchema(schema) + return schema } -describe('assertSupportedOutputSchema', () => { - it('accepts a representative subset schema (all supported keywords)', () => { - const schema = asserted({ - type: 'object', - description: 'a finding', - title: 'Finding', - properties: { - file: { type: 'string', description: 'path' }, - line: { type: 'integer' }, - severity: { type: 'string', enum: ['low', 'high'] }, - kind: { type: 'string', const: 'bug' }, - score: { type: 'number' }, - confirmed: { type: 'boolean' }, - parent: { type: 'null' }, - tags: { type: 'array', items: { type: 'string' } }, - nested: { - type: 'object', - properties: { x: { type: 'number', default: 3, examples: [1, 2] } }, - additionalProperties: false, +function violationsOf(schema: unknown, objectRoot = false): string[] { + try { + if (objectRoot) assertObjectJsonSchema(schema) + else assertSupportedJsonSchema(schema) + } catch (error: unknown) { + if (error instanceof JsonSchemaError) return error.violations + throw error + } + throw new Error('expected schema rejection') +} + +describe('the enforced raw JSON Schema subset', () => { + it('accepts every JSON root and every supported node', () => { + for (const schema of [ + { type: 'string' }, + { type: 'number' }, + { type: 'integer' }, + { type: 'boolean' }, + { type: 'null' }, + { type: 'array', items: { type: 'string' } }, + { + type: 'object', + properties: { + nested: { type: 'object', properties: {}, additionalProperties: false }, + free: {}, }, - anything: { type: 'array' }, + required: ['nested'], + additionalProperties: true, }, - required: ['file', 'line'], - additionalProperties: true, - }) - expect(schema.type).toBe('object') + { oneOf: [{ type: 'string' }, { type: 'number' }] }, + { description: 'any JSON', title: 'JSON', default: null, examples: [1, 'x'] }, + ]) { + expect(() => { assertSupportedJsonSchema(schema) }, JSON.stringify(schema)).not.toThrow() + } }) - it('rejects a non-object root (scalar/array-rooted schemas)', () => { - expect(violationsOf({ type: 'string' })).toEqual(['schema.type must be "object" (structured output is object-rooted)']) - expect(violationsOf({ type: 'array', items: { type: 'string' } })) - .toContain('schema.type must be "object" (structured output is object-rooted)') + it('retains an object-root guard only at consumers that need it', () => { + expect(assertedObject({ type: 'object' }).type).toBe('object') + for (const schema of [{}, { type: 'string' }, { type: 'array' }, { oneOf: [{ type: 'string' }, { type: 'null' }] }]) { + expect(violationsOf(schema, true)).toEqual(['schema.type must be "object" (structured output is object-rooted)']) + } }) - it('rejects non-object schema nodes and missing/unknown type', () => { - expect(violationsOf('nope')).toEqual(['schema must be a schema object']) + it('rejects non-schema nodes, unknown types, and type arrays', () => { expect(violationsOf(null)).toEqual(['schema must be a schema object']) expect(violationsOf([])).toEqual(['schema must be a schema object']) - expect(violationsOf({})).toEqual(['schema.type must be one of object/array/string/number/integer/boolean/null']) + expect(violationsOf('no')).toEqual(['schema must be a schema object']) expect(violationsOf({ type: 'tuple' })[0]).toMatch(/type must be one of/) - expect(violationsOf({ type: 'object', properties: { a: 'str' } })).toEqual(['schema.properties.a must be a schema object']) - }) - - it('rejects type ARRAYS with a dedicated message', () => { expect(violationsOf({ type: ['string', 'null'] })) .toEqual(['schema.type must be a single type string (type arrays are not supported)']) }) - it('rejects unsupported constraint keywords loudly (never accepted-then-ignored)', () => { - for (const keyword of ['oneOf', 'anyOf', 'allOf', 'not', 'pattern', 'minimum', 'maxLength', '$ref']) { - const bad = violationsOf({ type: 'object', [keyword]: [] }) - expect(bad.some(v => v.includes(`schema.${keyword} is not a supported keyword`))).toBe(true) - } + it('enforces oneOf vocabulary and its minimum branch count', () => { + expect(violationsOf({ oneOf: [] })).toEqual(['schema.oneOf must be an array of at least two schemas']) + expect(violationsOf({ oneOf: [{}] })).toEqual(['schema.oneOf must be an array of at least two schemas']) + expect(violationsOf({ oneOf: 'x' })).toEqual(['schema.oneOf must be an array of at least two schemas']) + expect(violationsOf({ type: 'string', oneOf: [{}, {}] })) + .toEqual(['schema cannot declare both type and oneOf']) + expect(violationsOf({ oneOf: [{ type: 'string' }, { type: 'number' }], items: {} })) + .toEqual(['schema.items is not supported beside oneOf']) + expect(violationsOf({ oneOf: [{ type: 'string' }, { type: 'weird' }] })[0]) + .toContain('schema.oneOf[1].type') }) - it('reports EVERY violation, not just the first', () => { - const bad = violationsOf({ + it('rejects unknown and misplaced keywords without accepted-then-ignored behavior', () => { + for (const keyword of ['anyOf', 'allOf', 'not', 'pattern', 'minimum', 'maxLength', '$ref']) { + expect(violationsOf({ type: 'object', [keyword]: [] })[0]).toContain(`schema.${keyword} is not a supported keyword`) + } + expect(violationsOf({ type: 'object', items: {} })) + .toEqual(['schema.items is not supported on type "object"']) + expect(violationsOf({ type: 'array', properties: {} })) + .toEqual(['schema.properties is not supported on type "array"']) + expect(violationsOf({ type: 'object', enum: ['x'] })) + .toEqual(['schema.enum is not supported on type "object"']) + expect(violationsOf({ type: 'array', const: null })) + .toEqual(['schema.const is not supported on type "array"']) + expect(violationsOf({ properties: {}, required: [], additionalProperties: true, items: {}, enum: [], const: null })) + .toEqual([ + 'schema.properties requires type or oneOf', + 'schema.required requires type or oneOf', + 'schema.additionalProperties requires type or oneOf', + 'schema.items requires type or oneOf', + 'schema.enum requires type or oneOf', + 'schema.const requires type or oneOf', + ]) + }) + + it('reports every independent schema violation', () => { + expect(violationsOf({ type: 'object', pattern: 'x', properties: { a: { type: 'weird' }, b: { type: 'string', minimum: 1 } }, - }) - expect(bad.length).toBe(3) + })).toHaveLength(3) }) - it('rejects keywords on the wrong type (items on object, properties on string, enum on object)', () => { - expect(violationsOf({ type: 'object', items: { type: 'string' } })) - .toEqual(['schema.items is not supported on type "object"']) - expect(violationsOf({ type: 'object', properties: { a: { type: 'string', properties: {} } } })) - .toEqual(['schema.properties.a.properties is not supported on type "string"']) - expect(violationsOf({ type: 'object', enum: [1] })) - .toEqual(['schema.enum is not supported on type "object"']) - expect(violationsOf({ type: 'object', properties: { a: { type: 'array', const: 1 } } })) - .toEqual(['schema.properties.a.const is not supported on type "array"']) - }) - - it('validates required: must be string[] naming declared properties', () => { - expect(violationsOf({ type: 'object', required: 'file' })) + it('validates object properties, required names, and openness', () => { + expect(violationsOf({ type: 'object', properties: [] })) + .toEqual(['schema.properties must be an object of schemas']) + expect(violationsOf({ type: 'object', properties: { a: 'x' } })) + .toEqual(['schema.properties.a must be a schema object']) + expect(violationsOf({ type: 'object', required: 'a' })) .toEqual(['schema.required must be an array of strings']) expect(violationsOf({ type: 'object', required: [1] })) .toEqual(['schema.required must be an array of strings']) - expect(violationsOf({ type: 'object', properties: { a: { type: 'string' } }, required: ['b'] })) - .toEqual(['schema.required names "b" which is not in properties']) - expect(violationsOf({ type: 'object', required: ['a'] })) - .toEqual(['schema.required names "a" which is not in properties']) - }) - - it('validates additionalProperties must be boolean and enum/const must be scalars', () => { - expect(violationsOf({ type: 'object', additionalProperties: {} })) + expect(violationsOf({ type: 'object', properties: {}, required: ['missing'] })) + .toEqual(['schema.required names "missing" which is not in properties']) + expect(violationsOf({ type: 'object', additionalProperties: 'yes' })) .toEqual(['schema.additionalProperties must be a boolean']) - expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: [] } } })) - .toEqual(['schema.properties.a.enum must be a non-empty array of scalars']) - expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: [{}] } } })) - .toEqual(['schema.properties.a.enum must be a non-empty array of scalars']) - expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: 'x' } } })) - .toEqual(['schema.properties.a.enum must be a non-empty array of scalars']) - expect(violationsOf({ type: 'object', properties: { a: { type: 'number', enum: [Number.NaN] } } })) - .toEqual(['schema.properties.a.enum must be a non-empty array of scalars']) - expect(violationsOf({ type: 'object', properties: { a: { type: 'string', const: {} } } })) - .toEqual(['schema.properties.a.const must be a scalar']) + expect(violationsOf({ type: 'object', properties: undefined })) + .toEqual(['schema.properties must be an object of schemas']) + expect(violationsOf({ type: 'object', properties: undefined, required: ['missing'] })) + .toEqual([ + 'schema.properties must be an object of schemas', + 'schema.required names "missing" which is not in properties', + ]) }) - it('rejects non-string description/title and non-JSON annotation payloads', () => { - expect(violationsOf({ type: 'object', description: 7 })) - .toEqual(['schema.description must be a string']) - expect(violationsOf({ type: 'object', title: 7 })) - .toEqual(['schema.title must be a string']) - expect(violationsOf({ type: 'object', default: () => 1 })) - .toEqual(['schema.default annotation must be JSON data']) - expect(violationsOf({ type: 'object', examples: [undefined] })) - .toEqual(['schema.examples annotation must be JSON data']) - expect(violationsOf({ type: 'object', examples: [Number.POSITIVE_INFINITY] })) - .toEqual(['schema.examples annotation must be JSON data']) - // A cyclic annotation payload is caught by the JSON-data walk. - const cyclicAnnotation: Record = {} - cyclicAnnotation.self = cyclicAnnotation - expect(violationsOf({ type: 'object', default: cyclicAnnotation })) - .toEqual(['schema.default annotation must be JSON data']) - // Object/array annotations that ARE JSON data pass. - asserted({ type: 'object', default: { a: [1, 'x', null, true] } }) + it('requires type-correct scalar enum and const values', () => { + for (const schema of [ + { type: 'string', enum: ['a'], const: 'a' }, + { type: 'number', enum: [1.5], const: 1.5 }, + { type: 'integer', enum: [1], const: 1 }, + { type: 'boolean', enum: [true], const: true }, + { type: 'null', enum: [null], const: null }, + ]) { + expect(() => { assertSupportedJsonSchema(schema) }, JSON.stringify(schema)).not.toThrow() + } + + expect(violationsOf({ type: 'string', enum: [] })) + .toEqual(['schema.enum must be a non-empty array of string values']) + expect(violationsOf({ type: 'number', enum: ['1'] })) + .toEqual(['schema.enum must be a non-empty array of number values']) + expect(violationsOf({ type: 'integer', enum: [1.5] })) + .toEqual(['schema.enum must be a non-empty array of integer values']) + expect(violationsOf({ type: 'number', enum: [Number.NaN] })) + .toEqual(['schema.enum must be a non-empty array of number values']) + expect(violationsOf({ type: 'number', const: -0 })) + .toEqual(['schema.const must be a number value']) + expect(violationsOf({ type: 'boolean', const: 1 })) + .toEqual(['schema.const must be a boolean value']) + expect(violationsOf({ type: 'string', enum: undefined })) + .toEqual(['schema.enum must be a non-empty array of string values']) }) - it('rejects a circular schema instead of recursing forever', () => { - const node: Record = { type: 'object' } - node.properties = { self: node } - expect(violationsOf(node)).toEqual(['schema.properties.self is circular']) + it('validates annotation types and lossless JSON payloads', () => { + expect(violationsOf({ description: 1 })).toEqual(['schema.description must be a string']) + expect(violationsOf({ title: 1 })).toEqual(['schema.title must be a string']) + for (const [key, value] of [ + ['default', undefined], + ['examples', [undefined]], + ['default', Number.POSITIVE_INFINITY], + ['examples', new Date(0)], + ] as const) { + expect(violationsOf({ [key]: value })).toEqual([`schema.${key} annotation must be lossless JSON data`]) + } + const cyclic: Record = {} + 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 = { type: 'object' } + cyclic.properties = { self: cyclic } + expect(violationsOf(cyclic)).toEqual(['schema.properties.self is circular']) const leaf = { type: 'string' } - asserted({ type: 'object', properties: { a: leaf, b: leaf } }) - }) - - 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(() => { assertSupportedJsonSchema({ type: 'object', properties: { a: leaf, b: leaf } }) }).not.toThrow() expect(violationsOf({ type: 'object', properties: new Map() })) .toEqual(['schema.properties must be an object of schemas']) - // A Date node is not a schema object even though Object.values(date) is []. expect(violationsOf({ type: 'object', properties: { at: new Date(0) } })) .toEqual(['schema.properties.at must be a schema object']) }) - it('rejects exotic annotation payloads that would serialize lossily', () => { - expect(violationsOf({ type: 'object', default: new Date(0) })) - .toEqual(['schema.default annotation must be JSON data']) - expect(violationsOf({ type: 'object', examples: [new Map()] })) - .toEqual(['schema.examples annotation must be JSON data']) + it('uses own-property semantics for required declarations', () => { + expect(violationsOf({ type: 'object', properties: {}, required: ['toString'] })) + .toEqual(['schema.required names "toString" which is not in properties']) }) }) -describe('validateStructuredValue', () => { - const schema = asserted({ - type: 'object', - properties: { - file: { type: 'string' }, - line: { type: 'integer' }, - score: { type: 'number' }, - confirmed: { type: 'boolean' }, - parent: { type: 'null' }, - severity: { type: 'string', enum: ['low', 'high'] }, - kind: { type: 'string', const: 'bug' }, - tags: { type: 'array', items: { type: 'string' } }, - free: { type: 'array' }, - nested: { type: 'object', properties: { x: { type: 'number' } }, required: ['x'], additionalProperties: false }, - }, - required: ['file'], +describe('validateJsonSchemaValue', () => { + it('validates scalar, array, object, and null roots', () => { + expect(validateJsonSchemaValue(asserted({ type: 'string' }), 'x')).toEqual([]) + expect(validateJsonSchemaValue(asserted({ type: 'number' }), 1.5)).toEqual([]) + expect(validateJsonSchemaValue(asserted({ type: 'integer' }), 2)).toEqual([]) + expect(validateJsonSchemaValue(asserted({ type: 'boolean' }), true)).toEqual([]) + expect(validateJsonSchemaValue(asserted({ type: 'null' }), null)).toEqual([]) + expect(validateJsonSchemaValue(asserted({ type: 'array', items: { type: 'string' } }), ['x'])).toEqual([]) + expect(validateJsonSchemaValue(asserted({ type: 'object' }), { x: 1 })).toEqual([]) }) - it('accepts a fully valid value (empty violations)', () => { - expect(validateStructuredValue(schema, { - file: 'a.ts', line: 3, score: 0.5, confirmed: true, parent: null, - severity: 'high', kind: 'bug', tags: ['x'], free: [1, { any: true }], nested: { x: 1 }, - })).toEqual([]) + it('rejects wrong scalar types and lossy numbers', () => { + expect(validateJsonSchemaValue(asserted({ type: 'string' }), 1)).toEqual(['"value" must be a string']) + expect(validateJsonSchemaValue(asserted({ type: 'number' }), '1')).toEqual(['"value" must be a number']) + expect(validateJsonSchemaValue(asserted({ type: 'number' }), Number.NaN)).toEqual(['"value" must be a finite JSON number']) + expect(validateJsonSchemaValue(asserted({ type: 'number' }), -0)).toEqual(['"value" must be a finite JSON number']) + expect(validateJsonSchemaValue(asserted({ type: 'integer' }), 1.5)).toEqual(['"value" must be an integer']) + expect(validateJsonSchemaValue(asserted({ type: 'boolean' }), 'true')).toEqual(['"value" must be a boolean']) + expect(validateJsonSchemaValue(asserted({ type: 'null' }), 0)).toEqual(['"value" must be null']) }) - it('reports missing required and wrong root type', () => { - expect(validateStructuredValue(schema, {})).toEqual(['missing required property "value.file"']) - expect(validateStructuredValue(schema, 'nope')).toEqual(['"value" must be an object']) - expect(validateStructuredValue(schema, [])).toEqual(['"value" must be an object']) + it('enforces scalar enum and const together', () => { + const schema = asserted({ type: 'string', enum: ['a', 'b'], const: 'a' }) + expect(validateJsonSchemaValue(schema, 'a')).toEqual([]) + expect(validateJsonSchemaValue(schema, 'c')).toEqual(['"value" must be one of ["a","b"]']) + expect(validateJsonSchemaValue(schema, 'b')).toEqual(['"value" must be "a"']) }) - it('type-checks every scalar branch with path-qualified messages', () => { - expect(validateStructuredValue(schema, { file: 1 })).toEqual(['"value.file" must be a string']) - expect(validateStructuredValue(schema, { file: 'a', line: 1.5 })).toEqual(['"value.line" must be an integer']) - expect(validateStructuredValue(schema, { file: 'a', line: 'x' })).toEqual(['"value.line" must be an integer']) - expect(validateStructuredValue(schema, { file: 'a', score: 'x' })).toEqual(['"value.score" must be a finite number']) - expect(validateStructuredValue(schema, { file: 'a', score: Number.NaN })).toEqual(['"value.score" must be a finite number']) - expect(validateStructuredValue(schema, { file: 'a', confirmed: 'yes' })).toEqual(['"value.confirmed" must be a boolean']) - expect(validateStructuredValue(schema, { file: 'a', parent: 0 })).toEqual(['"value.parent" must be null']) + it('validates object requiredness, nested values, and raw open defaults', () => { + const open = asserted({ + type: 'object', + properties: { + file: { type: 'string' }, + nested: { + type: 'object', + properties: { line: { type: 'integer' } }, + required: ['line'], + additionalProperties: false, + }, + }, + required: ['file'], + }) + expect(validateJsonSchemaValue(open, { file: 'a', extra: [1], nested: { line: 2 } })).toEqual([]) + expect(validateJsonSchemaValue(open, { nested: { line: 1 } })) + .toEqual(['missing required property "value.file"']) + expect(validateJsonSchemaValue(open, { file: 1, nested: {} })).toEqual([ + '"value.file" must be a string', + 'missing required property "value.nested.line"', + ]) + expect(validateJsonSchemaValue(open, { file: 'a', nested: { line: 1, extra: true } })) + .toEqual(['"value.nested.extra" is not a declared property (additionalProperties: false)']) + expect(validateJsonSchemaValue(open, 'x')).toEqual(['"value" must be an object']) }) - it('enforces enum membership and const equality', () => { - expect(validateStructuredValue(schema, { file: 'a', severity: 'mid' })) - .toEqual(['"value.severity" must be one of ["low","high"]']) - expect(validateStructuredValue(schema, { file: 'a', kind: 'feature' })) - .toEqual(['"value.kind" must be "bug"']) + it('treats present undefined as missing when required, then rejects other lossy objects', () => { + const required = asserted({ type: 'object', properties: { x: {} }, required: ['x'] }) + expect(validateJsonSchemaValue(required, { x: undefined })) + .toEqual(['missing required property "value.x"']) + expect(validateJsonSchemaValue(asserted({ type: 'object' }), { x: undefined })) + .toEqual(['"value" must be a lossless JSON object']) + expect(validateJsonSchemaValue(asserted({ type: 'object' }), new Date(0))) + .toEqual(['"value" must be an object']) }) - it('checks arrays per index; an items-less array accepts anything', () => { - expect(validateStructuredValue(schema, { file: 'a', tags: 'x' })).toEqual(['"value.tags" must be an array']) - expect(validateStructuredValue(schema, { file: 'a', tags: ['ok', 2] })).toEqual(['"value.tags[1]" must be a string']) - expect(validateStructuredValue(schema, { file: 'a', free: [{ deep: [1] }, null] })).toEqual([]) + it('validates dense arrays per index and rejects lossy arrays', () => { + const schema = asserted({ type: 'array', items: { type: 'integer' } }) + expect(validateJsonSchemaValue(schema, [1, 2])).toEqual([]) + expect(validateJsonSchemaValue(schema, [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', () => { - expect(validateStructuredValue(schema, { file: 'a', nested: {} })) - .toEqual(['missing required property "value.nested.x"']) - expect(validateStructuredValue(schema, { file: 'a', nested: { x: 1, y: 2 } })) - .toEqual(['"value.nested.y" is not a declared property (additionalProperties: false)']) - expect(validateStructuredValue(schema, { file: 'a', nested: 3 })) - .toEqual(['"value.nested" must be an object']) + it('validates exact-one oneOf semantics, including overlap', () => { + const disjoint = asserted({ oneOf: [{ type: 'string' }, { type: 'number' }] }) + expect(validateJsonSchemaValue(disjoint, 'x')).toEqual([]) + expect(validateJsonSchemaValue(disjoint, null)) + .toEqual(['"value" must match exactly one oneOf branch (matched 0)']) + const overlap = asserted({ oneOf: [{ type: 'number' }, { type: 'integer' }] }) + expect(validateJsonSchemaValue(overlap, 1)) + .toEqual(['"value" must match exactly one oneOf branch (matched 2)']) + expect(validateJsonSchemaValue(overlap, 1.5)).toEqual([]) }) - it('a required key present-but-undefined counts as missing', () => { - expect(validateStructuredValue(schema, { file: undefined })).toEqual(['missing required property "value.file"']) + it('an unconstrained schema accepts only lossless JSON values', () => { + const anyJson = asserted({}) + for (const value of [null, true, 1, 'x', [1], { x: null }]) { + expect(validateJsonSchemaValue(anyJson, value), JSON.stringify(value)).toEqual([]) + } + for (const value of [undefined, () => 1, Number.POSITIVE_INFINITY, -0, new Map()]) { + expect(validateJsonSchemaValue(anyJson, value)).toEqual(['"value" must be a lossless JSON value']) + } + const cyclic: Record = {} + 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', () => { - // required: ['toString'] must NOT be satisfied by Object.prototype.toString. - expect(validateStructuredValue( + it('uses own properties for requiredness, recursion, and closed-object checks', () => { + expect(validateJsonSchemaValue( asserted({ type: 'object', properties: { toString: { type: 'string' } }, required: ['toString'] }), {}, )).toEqual(['missing required property "value.toString"']) - // additionalProperties: false must flag an OWN `toString` key even though - // `'toString' in properties` is true via the prototype. - expect(validateStructuredValue( - asserted({ type: 'object', additionalProperties: false }), - { toString: 1 }, - )).toEqual(['"value.toString" is not a declared property (additionalProperties: false)']) - // A declared property the value does NOT carry must not be validated - // against the value's INHERITED member (constructor is a function on - // every plain object's prototype, not a carried property). - expect(validateStructuredValue( + expect(validateJsonSchemaValue(asserted({ type: 'object', additionalProperties: false }), { toString: 1 })) + .toEqual(['"value.toString" is not a declared property (additionalProperties: false)']) + expect(validateJsonSchemaValue( asserted({ type: 'object', properties: { constructor: { type: 'string' } } }), {}, )).toEqual([]) }) - it('a non-plain object value is not an object in the JSON sense', () => { - expect(validateStructuredValue(asserted({ type: 'object' }), new Date(0))) - .toEqual(['"value" must be an object']) - }) - - it('collects multiple violations across branches in one pass', () => { - expect(validateStructuredValue(schema, { line: 'x', severity: 'mid' })).toEqual([ - 'missing required property "value.file"', - '"value.line" must be an integer', - '"value.severity" must be one of ["low","high"]', - ]) - }) - - it('null-typed const/enum work through the scalar path', () => { - const nullish = asserted({ type: 'object', properties: { a: { type: 'null', const: null } } }) - expect(validateStructuredValue(nullish, { a: null })).toEqual([]) - }) - - it('rejects a non-object properties value in the schema walk', () => { - expect(violationsOf({ type: 'object', properties: [] })) - .toEqual(['schema.properties must be an object of schemas']) - }) - - it('an object schema without properties/required only type-checks its value', () => { - const bare = asserted({ type: 'object' }) - expect(validateStructuredValue(bare, { any: ['thing'] })).toEqual([]) - expect(validateStructuredValue(bare, 7)).toEqual(['"value" must be an object']) - }) - - it('validateStructuredValue throws on a type the assert would never let through (assertNever backstop)', () => { - const forged = { type: 'tuple' } as unknown as StructuredOutputSchema - expect(() => validateStructuredValue(forged, 1)).toThrow(/tuple/) + it('keeps assertNever as a forged-schema backstop', () => { + const forged = { type: 'tuple' } as unknown as JsonSchemaNode + expect(() => validateJsonSchemaValue(forged, 1)).toThrow(/tuple/) }) }) diff --git a/packages/core/tools/tests/properties.spec.ts b/packages/core/tools/tests/properties.spec.ts index 51b49fccd4..54c4088909 100644 --- a/packages/core/tools/tests/properties.spec.ts +++ b/packages/core/tools/tests/properties.spec.ts @@ -1,61 +1,91 @@ /** * Property-based tests for the tool-schema DSL (the property-testing Agent Note), including - * the the property-testing ↔ runtime-validation composition composition: generated args that satisfy a SchemaSpec must + * the property-testing ↔ runtime-validation composition: generated args that satisfy a ParameterSchemaSpec must * pass validateArgs, and targeted corruptions must be rejected. This closes the * validator/InferArgs drift risk noted in the arg-validation Agent Note. */ import { describe, expect, it } from 'vitest' import fc from 'fast-check' -import { schemaSpecToJsonSchema, validateArgs } from '@deepseek-ai/dsh-tools' -import type { SchemaProp, SchemaSpec } from '@deepseek-ai/dsh-tools' +import { parameterSchemaSpecToJsonSchema, validateArgs } from '@deepseek-ai/dsh-tools' +import type { ParameterPropertySpec, ParameterSchemaSpec, ValueSchemaSpec } from '@deepseek-ai/dsh-tools' + +/** Remove parameter-only requiredness before nesting a schema as an array item. */ +function asValueSchema(prop: ParameterPropertySpec): ValueSchemaSpec { + const { required: _required, ...schema } = prop + return schema +} // A leaf prop arbitrary (no nesting) with optional required/enum. -function leafPropArb(): fc.Arbitrary { +function leafPropArb(): fc.Arbitrary { return fc.oneof( - fc.record({ required: fc.boolean() }).map(({ required }): SchemaProp => ({ type: 'string', ...required ? { required: true } : {} })), - fc.record({ required: fc.boolean() }).map(({ required }): SchemaProp => ({ type: 'number', ...required ? { required: true } : {} })), - fc.record({ required: fc.boolean() }).map(({ required }): SchemaProp => ({ type: 'boolean', ...required ? { required: true } : {} })), + fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'string', ...required ? { required: true } : {} })), + fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'number', ...required ? { required: true } : {} })), + fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'integer', ...required ? { required: true } : {} })), + fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'boolean', ...required ? { required: true } : {} })), + fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'null', ...required ? { required: true } : {} })), + fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'json', ...required ? { required: true } : {} })), fc.record({ values: fc.uniqueArray(fc.string({ minLength: 1 }), { minLength: 1, maxLength: 3 }), required: fc.boolean() }) - .map(({ values, required }): SchemaProp => ({ type: 'string', enum: values, ...required ? { required: true } : {} })), + .map(({ values, required }): ParameterPropertySpec => ({ type: 'string', enum: values, ...required ? { required: true } : {} })), + fc.record({ value: fc.string(), required: fc.boolean() }) + .map(({ value, required }): ParameterPropertySpec => ({ type: 'string', const: value, ...required ? { required: true } : {} })), + fc.record({ required: fc.boolean() }) + .map(({ required }): ParameterPropertySpec => ({ + oneOf: [{ type: 'string' }, { type: 'null' }], + ...required ? { required: true } : {}, + })), ) } /** A prop arbitrary up to `depth` levels of nesting (objects and arrays). */ -function propArb(depth: number): fc.Arbitrary { +function propArb(depth: number): fc.Arbitrary { if (depth <= 0) return leafPropArb() return fc.oneof( { weight: 3, arbitrary: leafPropArb() }, { weight: 1, - arbitrary: fc.record({ properties: specArb(depth - 1), required: fc.boolean() }) - .map(({ properties, required }): SchemaProp => ({ type: 'object', properties, ...required ? { required: true } : {} })), + arbitrary: fc.record({ properties: specArb(depth - 1), required: fc.boolean(), additionalProperties: fc.boolean() }) + .map(({ properties, required, additionalProperties }): ParameterPropertySpec => ({ + type: 'object', + additionalProperties, + properties, + ...required ? { required: true } : {}, + })), }, { weight: 1, arbitrary: fc.record({ items: propArb(depth - 1), required: fc.boolean() }) - .map(({ items, required }): SchemaProp => ({ type: 'array', items, ...required ? { required: true } : {} })), + .map(({ items, required }): ParameterPropertySpec => ({ + type: 'array', + items: asValueSchema(items), + ...required ? { required: true } : {}, + })), }, ) } -function specArb(depth: number): fc.Arbitrary { +function specArb(depth: number): fc.Arbitrary { return fc.dictionary(fc.string({ minLength: 1, maxLength: 6 }), propArb(depth), { maxKeys: 4 }) } /** Generate a value that satisfies a prop (used to build valid args). */ -function valueForProp(prop: SchemaProp): fc.Arbitrary { +function valueForProp(prop: ParameterPropertySpec): fc.Arbitrary { + if ('oneOf' in prop) return fc.oneof(...prop.oneOf.map(valueForProp)) + if ('const' in prop) return fc.constant(prop.const) switch (prop.type) { case 'string': return prop.enum ? fc.constantFrom(...prop.enum) : fc.string() - case 'number': return fc.double({ noNaN: true, noDefaultInfinity: true }) + case 'number': return fc.double({ noNaN: true, noDefaultInfinity: true }).filter(value => !Object.is(value, -0)) + case 'integer': return fc.integer() case 'boolean': return fc.boolean() + case 'null': return fc.constant(null) case 'object': return prop.properties ? validArgsForSpec(prop.properties) : fc.constant({}) case 'array': return prop.items ? fc.array(valueForProp(prop.items), { maxLength: 3 }) : fc.constant([]) + case 'json': return fc.jsonValue() } } /** Generate args satisfying every required key of a spec (optionals included randomly). */ -function validArgsForSpec(spec: SchemaSpec): fc.Arbitrary> { +function validArgsForSpec(spec: ParameterSchemaSpec): fc.Arbitrary> { const entries = Object.entries(spec) return fc.tuple(...entries.map(([key, prop]) => fc.tuple( @@ -76,29 +106,29 @@ function validArgsForSpec(spec: SchemaSpec): fc.Arbitrary p.required === true).map(([k]) => k) } describe('schema DSL properties', () => { it('JSON Schema `required` equals the required:true keys at every level', () => { fc.assert(fc.property(specArb(2), (spec) => { - const checkLevel = (s: SchemaSpec, json: { required?: string[]; properties: Record }) => { + const checkLevel = (s: ParameterSchemaSpec, json: { required?: string[]; properties: Record }) => { expect(new Set(json.required ?? [])).toEqual(new Set(requiredKeys(s))) for (const [key, prop] of Object.entries(s)) { const propJson = json.properties[key] as Record - if (prop.type === 'object' && prop.properties) { + if ('type' in prop && prop.type === 'object' && prop.properties) { checkLevel(prop.properties, propJson as { required?: string[]; properties: Record }) } } } - checkLevel(spec, schemaSpecToJsonSchema(spec)) + checkLevel(spec, parameterSchemaSpecToJsonSchema(spec)) })) }) it('conversion is total (never throws) for any spec', () => { fc.assert(fc.property(specArb(3), (spec) => { - expect(() => schemaSpecToJsonSchema(spec)).not.toThrow() + expect(() => parameterSchemaSpecToJsonSchema(spec)).not.toThrow() })) }) diff --git a/packages/core/tools/tests/schema.spec.ts b/packages/core/tools/tests/schema.spec.ts new file mode 100644 index 0000000000..5bbd5b8b6d --- /dev/null +++ b/packages/core/tools/tests/schema.spec.ts @@ -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 = { type: 'array' } + schema.items = schema + expect(() => valueSchemaSpecToJsonSchema(schema as unknown as ValueSchemaSpec)).toThrow(/circular/) + + const properties: Record = {} + 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>().toEqualTypeOf<'a' | 'b'>() + expectTypeOf>().toEqualTypeOf<1>() + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + expectTypeOf>() + .toEqualTypeOf() + expectTypeOf>().toEqualTypeOf<{ id: number; label?: string }>() + expectTypeOf>().toEqualTypeOf<{ id: number } & Record>() + }) + + it('infers required and optional parameter keys', () => { + expectTypeOf>().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) + }) +}) diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 6c35f4f549..5ddac2cd31 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -5,8 +5,8 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { Agent } from '@deepseek-ai/dsh-agent' import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval' import ToolRegistry, { - defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError, - type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision, + defineTool, JsonSchemaError, parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError, + type InferArgs, type JsonValue, type ParameterSchemaSpec, type PreToolDecision, type PostToolDecision, type ToolExecution, type ToolExecutionResult, } from '@deepseek-ai/dsh-tools' @@ -775,13 +775,13 @@ describe('ToolRegistry', () => { }) 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 = { path: { type: 'string', required: true, description: 'Absolute path' }, offset: { type: 'number' }, limit: { type: 'number', description: 'Max lines' }, - } satisfies SchemaSpec - const jsonSchema = schemaSpecToJsonSchema(spec) + } satisfies ParameterSchemaSpec + const jsonSchema = parameterSchemaSpecToJsonSchema(spec) expect(jsonSchema).toEqual({ type: 'object', properties: { @@ -794,7 +794,7 @@ describe('defineTool / schema DSL', () => { }) it('handles empty spec (no properties, no required)', () => { - expect(schemaSpecToJsonSchema({})).toEqual({ + expect(parameterSchemaSpecToJsonSchema({})).toEqual({ type: 'object', properties: {}, }) @@ -804,19 +804,21 @@ describe('defineTool / schema DSL', () => { const spec = { config: { type: 'object', + additionalProperties: true, required: true, properties: { host: { type: 'string', required: true }, port: { type: 'number' }, }, }, - } satisfies SchemaSpec - const jsonSchema = schemaSpecToJsonSchema(spec) + } satisfies ParameterSchemaSpec + const jsonSchema = parameterSchemaSpecToJsonSchema(spec) expect(jsonSchema).toEqual({ type: 'object', properties: { config: { type: 'object', + additionalProperties: true, properties: { host: { type: 'string' }, port: { type: 'number' }, @@ -958,8 +960,8 @@ describe('schema DSL edge cases', () => { it('emits enum values in JSON Schema property', () => { const spec = { color: { type: 'string', enum: ['red', 'green', 'blue'], description: 'Color choice' }, - } satisfies SchemaSpec - const jsonSchema = schemaSpecToJsonSchema(spec) + } satisfies ParameterSchemaSpec + const jsonSchema = parameterSchemaSpecToJsonSchema(spec) expect(jsonSchema.properties['color']).toMatchObject({ type: 'string', enum: ['red', 'green', 'blue'], @@ -970,8 +972,8 @@ describe('schema DSL edge cases', () => { it('emits default value in JSON Schema property', () => { const spec = { limit: { type: 'number', default: 25 }, - } satisfies SchemaSpec - const jsonSchema = schemaSpecToJsonSchema(spec) + } satisfies ParameterSchemaSpec + const jsonSchema = parameterSchemaSpecToJsonSchema(spec) expect(jsonSchema.properties['limit']).toMatchObject({ type: 'number', default: 25, @@ -981,8 +983,8 @@ describe('schema DSL edge cases', () => { it('handles array items without nested properties (plain type array)', () => { const spec = { tags: { type: 'array', items: { type: 'string' } }, - } satisfies SchemaSpec - const jsonSchema = schemaSpecToJsonSchema(spec) + } satisfies ParameterSchemaSpec + const jsonSchema = parameterSchemaSpecToJsonSchema(spec) expect(jsonSchema.properties['tags']).toEqual({ type: 'array', items: { type: 'string' }, @@ -992,8 +994,8 @@ describe('schema DSL edge cases', () => { it('handles enum and default together in one property', () => { const spec = { level: { type: 'string', enum: ['low', 'high'], default: 'low' }, - } satisfies SchemaSpec - const jsonSchema = schemaSpecToJsonSchema(spec) + } satisfies ParameterSchemaSpec + const jsonSchema = parameterSchemaSpecToJsonSchema(spec) expect(jsonSchema.properties['level']).toMatchObject({ type: 'string', enum: ['low', 'high'], @@ -1004,8 +1006,8 @@ describe('schema DSL edge cases', () => { it('omits description, enum, default keys when not specified', () => { const spec = { bare: { type: 'string' }, - } satisfies SchemaSpec - const jsonSchema = schemaSpecToJsonSchema(spec) + } satisfies ParameterSchemaSpec + const jsonSchema = parameterSchemaSpecToJsonSchema(spec) const prop = jsonSchema.properties['bare'] as Record expect(prop).toEqual({ type: 'string' }) expect('description' in prop).toBe(false) @@ -1016,8 +1018,8 @@ describe('schema DSL edge cases', () => { it('handles array with no items (items omitted)', () => { const spec = { raw: { type: 'array' }, - } satisfies SchemaSpec - const jsonSchema = schemaSpecToJsonSchema(spec) + } satisfies ParameterSchemaSpec + const jsonSchema = parameterSchemaSpecToJsonSchema(spec) expect(jsonSchema.properties['raw']).toEqual({ type: 'array', }) @@ -1027,13 +1029,14 @@ describe('schema DSL edge cases', () => { const spec = { config: { type: 'object', + additionalProperties: true, properties: { host: { type: 'string' }, port: { type: 'number' }, }, }, - } satisfies SchemaSpec - const jsonSchema = schemaSpecToJsonSchema(spec) + } satisfies ParameterSchemaSpec + const jsonSchema = parameterSchemaSpecToJsonSchema(spec) expect(jsonSchema.properties['config']).toMatchObject({ type: 'object', properties: { @@ -1064,6 +1067,7 @@ describe('schema DSL optional and nested contracts', () => { type: 'array' items: { type: 'object' + additionalProperties: true properties: { host: { type: 'string'; required: true } port: { type: 'number' } @@ -1073,7 +1077,7 @@ describe('schema DSL optional and nested contracts', () => { }> expectTypeOf().toEqualTypeOf<{ names: string[] - servers?: { host: string; port?: number }[] + servers?: ({ host: string; port?: number } & Record)[] }>() }) @@ -1083,20 +1087,22 @@ describe('schema DSL optional and nested contracts', () => { type: 'array', items: { type: 'object', + additionalProperties: true, properties: { host: { type: 'string', required: true }, port: { type: 'number' }, }, }, }, - } satisfies SchemaSpec - expect(schemaSpecToJsonSchema(spec)).toEqual({ + } satisfies ParameterSchemaSpec + expect(parameterSchemaSpecToJsonSchema(spec)).toEqual({ type: 'object', properties: { servers: { type: 'array', items: { type: 'object', + additionalProperties: true, properties: { host: { type: 'string' }, port: { type: 'number' }, @@ -1178,7 +1184,7 @@ describe('validateArgs (the runtime-validation Agent Note, part 1)', () => { const spec = { path: { type: 'string', required: true }, limit: { type: 'number' }, - } satisfies SchemaSpec + } satisfies ParameterSchemaSpec expect(validateArgs(spec, { path: '/tmp' })).toEqual([]) expect(validateArgs(spec, { path: '/tmp', limit: 5 })).toEqual([]) // 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', () => { - 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, { path: undefined })).toEqual(['missing required property "path"']) }) 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([]) }) 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 expect(validateArgs(spec, {})).toEqual([]) }) @@ -1209,39 +1215,41 @@ describe('validateArgs (the runtime-validation Agent Note, part 1)', () => { s: { type: 'string' }, n: { type: 'number' }, b: { type: 'boolean' }, - } satisfies SchemaSpec + } satisfies ParameterSchemaSpec 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, { b: 'x' })).toEqual(['"b" must be a boolean']) }) 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: 'blue' })).toEqual(['"color" must be one of ["red","green"]']) }) - it('checks enum uniformly with the converter (enum on a non-string prop)', () => { - // The converter emits `enum` regardless of type; the validator must agree. - // `enum` is string[], so a number value can never be a member. - const spec = { n: { type: 'number', enum: ['1', '2'] } } as unknown as SchemaSpec - expect(validateArgs(spec, { n: 1 })).toEqual(['"n" must be one of ["1","2"]']) + it('enforces type-correct scalar enum declarations', () => { + const spec = { n: { type: 'number', enum: [1, 2] } } satisfies ParameterSchemaSpec + expect(validateArgs(spec, { n: 1 })).toEqual([]) + expect(validateArgs(spec, { n: 3 })).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)', () => { - const spec = { x: { type: 'weird' } } as unknown as SchemaSpec - expect(() => validateArgs(spec, { x: 1 })).toThrow(/unreachable variant.*validateArgs/) + it('rejects an unknown schema type at the author boundary', () => { + const spec = { x: { type: 'weird' } } as unknown as ParameterSchemaSpec + expect(() => validateArgs(spec, { x: 1 })).toThrow(JsonSchemaError) }) it('recurses into nested objects (and an object without properties only type-checks)', () => { const spec = { config: { type: 'object', + additionalProperties: true, required: true, properties: { host: { type: 'string', required: true }, port: { type: 'number' } }, }, - bag: { type: 'object' }, - } satisfies SchemaSpec + bag: { type: 'object', additionalProperties: true }, + } satisfies ParameterSchemaSpec expect(validateArgs(spec, { config: { host: 'h' }, bag: { anything: true } })).toEqual([]) expect(validateArgs(spec, { config: { port: 9 }, bag: 5 })).toEqual([ 'missing required property "config.host"', @@ -1253,7 +1261,7 @@ describe('validateArgs (the runtime-validation Agent Note, part 1)', () => { const spec = { tags: { type: 'array', items: { type: 'string' } }, raw: { type: 'array' }, - } satisfies SchemaSpec + } satisfies ParameterSchemaSpec expect(validateArgs(spec, { tags: ['a', 'b'], raw: [1, {}, 'x'] })).toEqual([]) expect(validateArgs(spec, { tags: ['a', 2] })).toEqual(['"tags[1]" must be a string']) // a non-array value for an array-typed prop @@ -1264,9 +1272,9 @@ describe('validateArgs (the runtime-validation Agent Note, part 1)', () => { const spec = { servers: { 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([ 'missing required property "servers[1].host"', ]) diff --git a/packages/core/tools/tests/ts-types.spec.ts b/packages/core/tools/tests/ts-types.spec.ts index df30a58238..14b14f7ecd 100644 --- a/packages/core/tools/tests/ts-types.spec.ts +++ b/packages/core/tools/tests/ts-types.spec.ts @@ -1,20 +1,37 @@ import { describe, expect, it } from 'vitest' import { jsonSchemaToTs, renderToolsSdk } from '@deepseek-ai/dsh-tools/src/ts-types.ts' -import { schemaSpecToJsonSchema } from '@deepseek-ai/dsh-tools' +import { parameterSchemaSpecToJsonSchema } from '@deepseek-ai/dsh-tools' import type { ToolSchema } from '@deepseek-ai/dsh-llm' describe('jsonSchemaToTs', () => { - it('maps the defineTool DSL subset', () => { + it('maps every unified schema construct', () => { const cases: [unknown, string][] = [ [{ type: 'string' }, 'string'], [{ type: 'number' }, 'number'], + [{ type: 'integer' }, 'number'], [{ type: 'boolean' }, 'boolean'], + [{ type: 'null' }, 'null'], [{ type: 'string', enum: ['a', 'b'] }, '"a" | "b"'], + [{ type: 'number', enum: [1, 2] }, '1 | 2'], + [{ type: 'integer', const: 2 }, '2'], + [{ type: 'boolean', const: true }, 'true'], + [{ type: 'null', const: null }, 'null'], + [{ type: 'string', enum: ['a', 'b'], const: 'a' }, '"a"'], + [{ oneOf: [{ type: 'string' }, { type: 'null' }] }, 'string | null'], [{ type: 'array', items: { type: 'number' } }, 'number[]'], [{ type: 'array', items: { type: 'string', enum: ['x', 'y'] } }, '("x" | "y")[]'], - [{ type: 'array' }, 'unknown[]'], - [{ type: 'object' }, 'Record'], - [{ type: 'object', properties: {} }, 'Record'], + [{ type: 'array' }, 'JsonValue[]'], + [{ type: 'object' }, 'Record'], + [{ type: 'object', additionalProperties: false }, 'Record'], + [{ type: 'object', properties: {} }, 'Record'], + [{ type: 'object', properties: {}, additionalProperties: false }, 'Record'], + [{ + type: 'object', + additionalProperties: false, + properties: { id: { type: 'integer' }, label: { type: 'string' } }, + required: ['id'], + }, ['{', ' id: number;', ' label?: string;', '}'].join('\n')], + [{}, 'JsonValue'], ] for (const [schema, expected] of cases) { expect(jsonSchemaToTs(schema), JSON.stringify(schema)).toBe(expected) @@ -22,11 +39,12 @@ describe('jsonSchemaToTs', () => { }) it('renders objects with required/optional keys, nested shapes, and per-property docs', () => { - const schema = schemaSpecToJsonSchema({ + const schema = parameterSchemaSpecToJsonSchema({ path: { type: 'string', required: true, description: 'Absolute file path' }, limit: { type: 'number' }, opts: { type: 'object', + additionalProperties: true, properties: { deep: { type: 'boolean', required: true } }, }, }) @@ -37,8 +55,8 @@ describe('jsonSchemaToTs', () => { ' limit?: number;', ' opts?: {', ' deep: boolean;', - ' };', - '}', + ' } & Record;', + '} & Record', ].join('\n')) }) @@ -48,9 +66,6 @@ describe('jsonSchemaToTs', () => { null, 42, 'string-schema', - {}, - { type: 'integer' }, - { type: 'null' }, { oneOf: [{ type: 'string' }] }, { $ref: '#/defs/x' }, { type: 'object', properties: 7 }, @@ -61,18 +76,13 @@ describe('jsonSchemaToTs', () => { for (const schema of cases) { expect(() => jsonSchemaToTs(schema), JSON.stringify(schema)).not.toThrow() } - expect(jsonSchemaToTs({ type: 'integer' })).toBe('unknown') expect(jsonSchemaToTs({ oneOf: [] })).toBe('unknown') - expect(jsonSchemaToTs({ type: 'object', properties: 7 })).toBe('Record') - expect(jsonSchemaToTs({ type: 'object', properties: { bad: { $ref: 'x' } }, required: ['bad'] })).toContain('bad: unknown;') - // A non-string-only enum degrades to plain string; an empty one too. - expect(jsonSchemaToTs({ type: 'string', enum: [1, 2] })).toBe('string') - expect(jsonSchemaToTs({ type: 'string', enum: [] })).toBe('string') - // A hostile required list only accepts string members. - expect(jsonSchemaToTs({ type: 'object', properties: { a: { type: 'string' } }, required: [7] })).toContain('a?: string;') - // A property VALUE that is not an object degrades to unknown (and can - // carry no description). - expect(jsonSchemaToTs({ type: 'object', properties: { weird: 42 } })).toContain('weird?: unknown;') + expect(jsonSchemaToTs({ type: 'object', properties: 7 })).toBe('unknown') + expect(jsonSchemaToTs({ type: 'object', properties: { bad: { $ref: 'x' } }, required: ['bad'] })).toBe('unknown') + expect(jsonSchemaToTs({ type: 'string', enum: [1, 2] })).toBe('unknown') + expect(jsonSchemaToTs({ type: 'string', enum: [] })).toBe('unknown') + expect(jsonSchemaToTs({ type: 'object', properties: { a: { type: 'string' } }, required: [7] })).toBe('unknown') + expect(jsonSchemaToTs({ type: 'object', properties: { weird: 42 } })).toBe('unknown') }) it('escapes a comment-closer inside a description so the generated JSDoc cannot end early', () => { @@ -89,17 +99,18 @@ describe('renderToolsSdk', () => { const bash: ToolSchema = { name: 'bash', description: 'Run a shell command.', - parameters: schemaSpecToJsonSchema({ command: { type: 'string', required: true } }) as unknown as Record, + parameters: parameterSchemaSpecToJsonSchema({ command: { type: 'string', required: true } }) as unknown as Record, } const exotic: ToolSchema = { name: 'my-mcp.tool', description: 'Exotic name.', - parameters: schemaSpecToJsonSchema({}) as unknown as Record, + parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record, } it('declares every tool in lexicographic order with quoted keys for exotic names', () => { const text = renderToolsSdk([exotic, bash]) expect(text).toContain('declare const tools: {') + expect(text).toContain('type JsonValue = null | boolean | number | string') expect(text.indexOf('bash(args:')).toBeGreaterThan(0) expect(text).toContain('"my-mcp.tool"(args:') expect(text.indexOf('bash(args:')).toBeLessThan(text.indexOf('"my-mcp.tool"(args:')) diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index 09aa2d24b7..9d2cce1570 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -14,7 +14,7 @@ import type { Context } from 'cordis' import type { ContinuationStop } from '@deepseek-ai/dsh-agent' import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' 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. */ export const STRUCTURED_OUTPUT_TOOL = 'structured_output' @@ -44,10 +44,10 @@ export interface StructuredAttachment { * its creation window. Child disposal removes every registration. * @param childCtx - the child agent's scope context (`setup`'s argument). * @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). */ -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 * authoritative `tools/result` notification. The execution object's identity @@ -75,7 +75,7 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut childCtx.tools.register({ ...schemaEntry, execute(args: unknown, exec: ToolExecution): Promise { - const violations = validateStructuredValue(schema, args) + const violations = validateJsonSchemaValue(schema, args) // ToolArgsError → isError result with INVALID_ARGS: the model retries // within the same turn, exactly like a schema-validated defineTool call. if (violations.length > 0) throw new ToolArgsError(violations) diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index e50457bcb4..56a78a225f 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -7,7 +7,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as Invariants from '@deepseek-ai/dsh-invariants' 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 { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { startInProcessRun } from '../src/index.ts' @@ -27,7 +27,7 @@ interface SetupOptions { codeRun?: (request: CodeRunRequestLike) => Promise<{ logs: never[]; value?: unknown }> } -const SCHEMA: StructuredOutputSchema = { +const SCHEMA: ObjectJsonSchema = { type: 'object', properties: { answer: { type: 'number' }, note: { type: 'string' } }, required: ['answer'], @@ -320,17 +320,17 @@ describe('in-process structured output', () => { it('rejects a schema outside the subset loud, before any child exists', async () => { const { ctx, parent } = await setup([]) await expect(ctx.subagents.start('spawn', structuredRequest(parent, { - outputSchema: { type: 'object', oneOf: [] } as unknown as StructuredOutputSchema, - }))).rejects.toThrow(/unsupported output schema/) + outputSchema: { type: 'object', oneOf: [] } as unknown as ObjectJsonSchema, + }))).rejects.toThrow(/unsupported JSON schema/) 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([]) // Semantic assertion runs before provider startup. await expect(ctx.subagents.start('spawn', structuredRequest(parent, { - outputSchema: { type: 'object', default: () => {} } as unknown as StructuredOutputSchema, - }))).rejects.toThrow(/unsupported output schema.*annotation must be JSON data/) + outputSchema: { type: 'object', default: () => {} } as unknown as ObjectJsonSchema, + }))).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 () => { @@ -547,7 +547,7 @@ describe('in-process structured output', () => { }) it('two concurrent structured children each see their OWN schema', async () => { - const otherSchema: StructuredOutputSchema = { + const otherSchema: ObjectJsonSchema = { type: 'object', properties: { verdict: { type: 'string', enum: ['real', 'bogus'] } }, required: ['verdict'], diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 00655f1f51..95e9300b0b 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -32,7 +32,7 @@ import { randomUUID } from 'node:crypto' import { Context, Service } from 'cordis' import { scopeTarget } 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 type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -242,7 +242,7 @@ export class SubagentService extends Service { } this.assertCapabilities(provider, request) assertSubagentMaxDepth(request.maxDepth) - if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema) + if (request.outputSchema !== undefined) assertObjectJsonSchema(request.outputSchema) const parent = request.parent const run = await provider.start(request) diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 1b1645d89b..7448f087d4 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -10,7 +10,7 @@ import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' import type { Branded } from '@deepseek-ai/dsh-brand' import type { ContentBlock } from '@deepseek-ai/dsh-llm' 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. */ export type SubagentRunId = Branded<'SubagentRunId'> @@ -70,11 +70,11 @@ export interface SubagentStartRequest { /** Per-child agent options (model and plugin-defined extension fields). */ 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; * 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 * computed depth must be less than or equal to this non-negative safe diff --git a/packages/tasks/tool-tasks/src/index.ts b/packages/tasks/tool-tasks/src/index.ts index 2d556cada9..3f8256be15 100644 --- a/packages/tasks/tool-tasks/src/index.ts +++ b/packages/tasks/tool-tasks/src/index.ts @@ -41,7 +41,7 @@ export function statusLine(snapshot: TaskSnapshot): string { : `[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 { if (value.length === 0) { throw new Error(`invalid task_id: expected a non-empty string, got ${JSON.stringify(value)}`) diff --git a/packages/todo/tool-todo/src/index.ts b/packages/todo/tool-todo/src/index.ts index 039d2085f7..919e53f7bb 100644 --- a/packages/todo/tool-todo/src/index.ts +++ b/packages/todo/tool-todo/src/index.ts @@ -28,7 +28,7 @@ const DESCRIPTION = + '(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 * 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.', items: { type: 'object', + additionalProperties: true, properties: { content: { type: 'string', required: true, description: 'What the task is — a short imperative line.' }, status: { diff --git a/packages/ui/tool-ask-user/src/index.ts b/packages/ui/tool-ask-user/src/index.ts index 2591b28ddd..47cb6e8d22 100644 --- a/packages/ui/tool-ask-user/src/index.ts +++ b/packages/ui/tool-ask-user/src/index.ts @@ -27,6 +27,7 @@ export function apply(ctx: Context): void { description: 'Questions to ask the user before continuing.', items: { type: 'object', + additionalProperties: true, properties: { 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.' }, @@ -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.', items: { type: 'object', + additionalProperties: true, properties: { label: { type: 'string', required: true, description: 'Short user-facing option label.' }, description: { type: 'string', description: 'One sentence explaining the tradeoff or impact.' }, diff --git a/packages/workflow/tool-workflow/src/index.ts b/packages/workflow/tool-workflow/src/index.ts index 2a8ef96682..71121730e5 100644 --- a/packages/workflow/tool-workflow/src/index.ts +++ b/packages/workflow/tool-workflow/src/index.ts @@ -131,6 +131,7 @@ export function apply(ctx: Context, config: Config): void { }, meta: { type: 'object', + additionalProperties: true, required: true, description: 'The workflow identity block (plain JSON — never code).', properties: { @@ -142,6 +143,7 @@ export function apply(ctx: Context, config: Config): void { description: 'Optional phase declarations matched by phase() calls.', items: { type: 'object', + additionalProperties: true, properties: { 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.' }, @@ -154,6 +156,7 @@ export function apply(ctx: Context, config: Config): void { }, args: { 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": [...]}).', }, }, diff --git a/packages/workflow/workflow-workerthread/src/runtime.ts b/packages/workflow/workflow-workerthread/src/runtime.ts index 535917fc42..38194f4c03 100644 --- a/packages/workflow/workflow-workerthread/src/runtime.ts +++ b/packages/workflow/workflow-workerthread/src/runtime.ts @@ -15,8 +15,8 @@ import * as vm from 'node:vm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' -import { assertSupportedOutputSchema, OutputSchemaError } from '@deepseek-ai/dsh-tools' -import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools' +import { assertObjectJsonSchema, JsonSchemaError } from '@deepseek-ai/dsh-tools' +import type { ObjectJsonSchema } from '@deepseek-ai/dsh-tools' import { isFatalWorkflowError, WorkflowError } from '@deepseek-ai/dsh-workflow' import type { WorkflowAgentEndInfo, @@ -350,7 +350,7 @@ export class WorkflowExecution { phase?: string provider?: string model?: string - schema?: StructuredOutputSchema + schema?: ObjectJsonSchema } { if (rawOpts === undefined) return {} let opts: unknown @@ -377,14 +377,14 @@ export class WorkflowExecution { throw new WorkflowError(`agent() option "${key}" must be a string`, 'INVALID_ARGUMENT') } } - let schema: StructuredOutputSchema | undefined + let schema: ObjectJsonSchema | undefined if (record.schema !== undefined) { try { - assertSupportedOutputSchema(record.schema) + assertObjectJsonSchema(record.schema) schema = record.schema } catch (error: unknown) { - /* v8 ignore next -- defensive rethrow arm: assertSupportedOutputSchema only throws OutputSchemaError */ - if (!(error instanceof OutputSchemaError)) throw error + /* v8 ignore next -- defensive rethrow arm: assertObjectJsonSchema only throws JsonSchemaError */ + if (!(error instanceof JsonSchemaError)) throw error throw new WorkflowError(`agent() schema is outside the supported subset — ${error.message}`, 'UNSUPPORTED_SCHEMA', { cause: error }) } } diff --git a/packages/workflow/workflow-workerthread/src/types.ts b/packages/workflow/workflow-workerthread/src/types.ts index 7e2bfff36c..9d7ddf6808 100644 --- a/packages/workflow/workflow-workerthread/src/types.ts +++ b/packages/workflow/workflow-workerthread/src/types.ts @@ -6,7 +6,7 @@ */ 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' /** @@ -41,7 +41,7 @@ export interface ChildStartRequest { /** The child's prompt text. */ prompt: string /** 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. */ provider?: string /** The per-child model override, if the call passed one. */ diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 5cb84266da..ed53ac469c 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -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/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": "SchemaSpec", "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": "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": "ToolExecutionToken", "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": "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": "StructuredScalar", "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": "StructuredSchemaNode", "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": "JsonSchemaScalar", "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": "JsonSchemaNode", "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": "AskUserQuestionItem", "source": "packages/ui/user-interaction/src/index.ts" }, From 66c36e7325b1a496557a20e9c71c75e01a113692 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 03:08:35 +0800 Subject: [PATCH 002/103] feat: add canonical typed tool outputs --- .../2026-06-17-filesystem-capability-seam.md | 2 +- ...26-07-02-result-time-applied-hunk-diffs.md | 16 +- ...026-07-06-tool-result-retention-library.md | 2 +- .../2026-07-07-tool-call-timeout-policy.md | 5 +- .../2026-07-08-tool-output-spill-files.md | 8 +- ...0-canonical-tool-output-contract.i18n.yaml | 6 + ...26-07-20-canonical-tool-output-contract.md | 76 +++ ...07-20-canonical-tool-output-contract.zh.md | 76 +++ .../2026-06-17-filesystem-tool-schemas.md | 2 +- .../feature/2026-06-30-interception-seams.md | 6 +- ...6-07-08-self-referential-cordis-toolset.md | 2 +- docs/architecture.md | 5 +- docs/config-catalog.md | 14 +- docs/cookbook/adding-a-tool.i18n.yaml | 4 +- docs/cookbook/adding-a-tool.md | 21 +- docs/cookbook/adding-a-tool.zh.md | 21 +- docs/cordis-catalog/events.md | 10 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/session.md | 25 +- docs/core-data-structures/tools.md | 123 +++-- docs/event-producer-consumer.md | 10 +- docs/persistence-catalog.md | 35 +- docs/tool-catalog.md | 2 +- docs/user/develop/basic/index.i18n.yaml | 4 +- docs/user/develop/basic/index.md | 6 +- docs/user/develop/basic/index.zh.md | 6 +- docs/user/develop/basic/tool.i18n.yaml | 4 +- docs/user/develop/basic/tool.md | 57 ++- docs/user/develop/basic/tool.zh.md | 57 ++- docs/user/develop/practice/index.i18n.yaml | 4 +- docs/user/develop/practice/index.md | 6 +- docs/user/develop/practice/index.zh.md | 6 +- .../system-prompt.expected.md | 2 +- .../tool-schemas.expected.json | 2 +- .../snapshots/cancel-tool-calls/session.jsonl | 4 +- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../escalation-rejected/session.jsonl | 2 +- .../fs-escalation-approved/session.jsonl | 2 +- .../snapshots/fs-policy-reject/session.jsonl | 2 +- .../tests/snapshots/fs-write/session.jsonl | 2 +- .../hook-cc-posttool-block/session.jsonl | 2 +- .../hook-cc-pretool-ask/session.jsonl | 2 +- .../hook-cc-pretool-deny/session.jsonl | 2 +- .../hook-codex-posttool-block/session.jsonl | 2 +- .../hook-codex-pretool-block/session.jsonl | 2 +- .../session.2.jsonl | 2 +- packages/bash/tool-bash/README.md | 2 + packages/bash/tool-bash/src/index.ts | 97 +++- packages/bash/tool-bash/tests/tools.spec.ts | 36 +- .../tests/compact-loop-repro.spec.ts | 4 +- .../tests/tool-result-prune.spec.ts | 4 +- .../time-context/tests/time-context.spec.ts | 4 +- .../context/workspace-context/src/index.ts | 3 +- .../tests/workspace-context.spec.ts | 38 +- packages/cordis/tool-cordis/README.md | 2 + .../cordis/tool-cordis/src/api-catalog.ts | 28 +- .../cordis/tool-cordis/src/fiber-state.ts | 4 +- packages/cordis/tool-cordis/src/guard.ts | 84 ++-- packages/cordis/tool-cordis/src/index.ts | 67 ++- packages/cordis/tool-cordis/src/inspect.ts | 11 +- .../tool-cordis/tests/cross-mount.spec.ts | 3 +- packages/cordis/tool-cordis/tests/helpers.ts | 28 +- .../cordis/tool-cordis/tests/inspect.spec.ts | 2 + .../cordis/tool-cordis/tests/mount.spec.ts | 95 +++- .../tool-cordis/tests/sandbox-context.spec.ts | 8 +- .../tool-cordis/tests/unmount-hmr.spec.ts | 2 + packages/core/agent-loop/src/tool-calls.ts | 5 +- .../agent-loop/tests/agent-initiator.spec.ts | 10 +- packages/core/agent-loop/tests/cancel.spec.ts | 6 +- .../tests/contract-regressions.spec.ts | 30 +- .../agent-loop/tests/coverage-edges.spec.ts | 10 +- .../agent-loop/tests/interception.spec.ts | 16 +- packages/core/agent-loop/tests/loop.spec.ts | 48 +- .../agent-loop/tests/request-cache.e2e.ts | 4 +- .../tests/request-reconstruction.spec.ts | 4 +- .../agent-loop/tests/request-recovery.spec.ts | 8 +- .../agent-loop/tests/scope-lifecycle.spec.ts | 10 +- .../core/agent-loop/tests/tool-calls.spec.ts | 46 +- .../core/agent-loop/tests/tool-order.spec.ts | 4 +- .../core/agent-loop/tests/turn-stop.spec.ts | 4 +- packages/core/session/README.md | 2 + packages/core/session/src/repair.ts | 5 +- packages/core/session/src/types.ts | 25 +- packages/core/session/tests/repair.spec.ts | 2 +- packages/core/tools/README.md | 23 +- packages/core/tools/src/code-mode.ts | 38 +- packages/core/tools/src/index.ts | 252 +++++++--- packages/core/tools/src/schema.ts | 85 ++-- packages/core/tools/src/testing.ts | 42 ++ packages/core/tools/tests/code-mode.spec.ts | 28 +- .../core/tools/tests/execution-mode.spec.ts | 23 +- packages/core/tools/tests/scoped.spec.ts | 23 +- packages/core/tools/tests/tools.spec.ts | 441 ++++++++++++++++-- .../examples/acp-demo/tests/acp-agent.spec.ts | 3 +- .../agent-spine-demo/tests/agent-core.spec.ts | 3 +- .../examples/cli-demo/tests/cli-demo.spec.ts | 8 +- packages/examples/cli-demo/tests/cli.spec.ts | 6 +- packages/fs/tool-fs-search/README.md | 4 +- packages/fs/tool-fs-search/src/glob.ts | 54 ++- packages/fs/tool-fs-search/src/grep.ts | 91 +++- packages/fs/tool-fs-search/src/surface.ts | 27 ++ .../tool-fs-search/tests/integration.spec.ts | 10 +- .../fs/tool-fs-search/tests/tools.spec.ts | 147 +++++- packages/fs/tool-fs/README.md | 2 + packages/fs/tool-fs/src/edit.ts | 31 +- packages/fs/tool-fs/src/read-render.ts | 2 +- packages/fs/tool-fs/src/read.ts | 47 +- packages/fs/tool-fs/src/write.ts | 41 +- packages/fs/tool-fs/tests/integration.spec.ts | 28 +- packages/fs/tool-fs/tests/tools.spec.ts | 38 +- packages/goal/tool-goal/README.md | 2 + packages/goal/tool-goal/src/index.ts | 93 +++- .../goal/tool-goal/tests/tool-goal.spec.ts | 47 +- packages/guard/repeat-tool-guard/src/index.ts | 3 +- .../tests/repeat-tool-guard.spec.ts | 10 +- packages/hooks/hooks-claude/src/index.ts | 3 +- .../hooks/hooks-claude/tests/bridge.spec.ts | 12 +- .../hooks-claude/tests/coverage-cases.ts | 48 +- packages/hooks/hooks-codex/src/index.ts | 3 +- .../hooks/hooks-codex/tests/bridge.spec.ts | 4 +- .../hooks/hooks-codex/tests/coverage-cases.ts | 52 +-- packages/llm/llm-retry/tests/retry.spec.ts | 4 +- packages/mcp/mcp-client/README.md | 10 +- packages/mcp/mcp-client/src/index.ts | 2 + packages/mcp/mcp-client/src/tools.ts | 69 ++- .../mcp/mcp-client/tests/mcp-client.spec.ts | 134 +++++- .../session-persistence/tests/contract.ts | 2 +- packages/skill/tool-skill/README.md | 2 +- packages/skill/tool-skill/src/index.ts | 55 ++- .../skill/tool-skill/tests/tool-skill.spec.ts | 16 +- packages/spill/spill-policy/README.md | 8 +- packages/spill/spill-policy/src/index.ts | 14 +- .../spill-policy/tests/spill-policy.spec.ts | 38 +- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent-inprocess/src/structured.ts | 15 +- .../tests/structured.spec.ts | 35 +- .../tests/subagent-spawn.spec.ts | 5 +- packages/subagent/tool-subagent/README.md | 4 +- packages/subagent/tool-subagent/src/index.ts | 52 ++- .../tool-subagent/tests/tool-subagent.spec.ts | 8 + packages/support/invariants/src/index.ts | 2 +- .../invariants/tests/invariants.spec.ts | 6 +- packages/tasks/tool-tasks/README.md | 2 + packages/tasks/tool-tasks/src/index.ts | 110 ++++- .../tasks/tool-tasks/tests/tool-tasks.spec.ts | 40 +- packages/timeout/timeout-policy/README.md | 2 +- packages/timeout/timeout-policy/src/index.ts | 5 +- .../tests/timeout-policy.spec.ts | 36 +- packages/todo/tool-todo/README.md | 2 +- packages/todo/tool-todo/src/index.ts | 49 +- .../todo/tool-todo/tests/tool-todo.spec.ts | 5 + packages/ui/acp/src/index.ts | 3 +- packages/ui/acp/tests/stream-update.spec.ts | 17 +- packages/ui/acp/tests/turns.spec.ts | 8 +- packages/ui/tool-ask-user/README.md | 4 +- packages/ui/tool-ask-user/src/index.ts | 30 +- .../tool-ask-user/tests/tool-ask-user.spec.ts | 12 +- packages/ui/tui/src/index.ts | 4 +- packages/ui/tui/tests/tui.snapshot.ts | 5 +- packages/ui/tui/tests/tui.spec.ts | 25 +- packages/web/tool-web/README.md | 2 + packages/web/tool-web/src/fetch.ts | 43 +- packages/web/tool-web/src/search.ts | 39 +- .../web/tool-web/tests/integration.spec.ts | 17 +- packages/web/tool-web/tests/tool-web.spec.ts | 33 +- packages/workflow/tool-ralph/README.md | 2 +- packages/workflow/tool-ralph/src/index.ts | 27 +- .../tool-ralph/tests/tool-ralph.spec.ts | 8 +- packages/workflow/tool-workflow/README.md | 4 +- packages/workflow/tool-workflow/src/index.ts | 30 +- .../tool-workflow/tests/tool-workflow.spec.ts | 11 +- scripts/type-equiv.manifest.json | 4 + 173 files changed, 3298 insertions(+), 954 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md create mode 100644 .agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md create mode 100644 packages/core/tools/src/testing.ts create mode 100644 packages/fs/tool-fs-search/src/surface.ts diff --git a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md index e76ca22e2d..7fa2bde08c 100644 --- a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md @@ -122,7 +122,7 @@ The root plugin registers the full suite by composing the per-tool registration ## Testing -Tests follow the package boundary, not only the user-visible tools: the service seam in `dsh-fs`; real filesystem behavior through the `ctx.fs` interface in `dsh-fs-local` (resolution, symlinks, streaming, binary/UTF-8 rejection, unconditional and version-guarded writes, literal-edit semantics, line-ending preservation, structured `FsError` codes); the consumer surface in `dsh-tool-fs` against the real local provider (mock only the model/clock, never the collaborator); and integration through `ctx.tools.execute()` with and without `dsh-fs-policy`, world-verified by reading files back from disk rather than trusting the returned `ContentBlock[]`. The observed-state/owner-derivation policy is tested in `dsh-fs-policy`, not here. +Tests follow the package boundary, not only the user-visible tools: the service seam in `dsh-fs`; real filesystem behavior through the `ctx.fs` interface in `dsh-fs-local` (resolution, symlinks, streaming, binary/UTF-8 rejection, unconditional and version-guarded writes, literal-edit semantics, line-ending preservation, structured `FsError` codes); the consumer surface in `dsh-tool-fs` against the real local provider (mock only the model/clock, never the collaborator); and integration through `ctx.tools.execute()` with and without `dsh-fs-policy`, world-verified by reading files back from disk rather than trusting either the canonical value or rendered content. The observed-state/owner-derivation policy is tested in `dsh-fs-policy`, not here. The defensive-pattern classes this repo has been bitten by are pinned directly: diff --git a/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md b/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md index 8ddd1e8941..ce0f146b39 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md +++ b/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md @@ -14,24 +14,20 @@ The obstacle is a seam boundary: `presentResult(args, result)` is a **pure funct Add a **persisted, tool-private presentation channel** so a tool's `execute` can attach a result-time render payload that survives replay, and use it to carry the applied-hunk diff. -### 1. A `meta` channel on the tool result (core) +### 1. A replayable presentation projection on canonical tool output (core) -`ToolDefinition.execute` may now return either its model-facing `ContentBlock[]` (unchanged, the common case) OR `{ content: ContentBlock[]; meta?: unknown }`: +The original implementation let `execute` return `{ content, meta }`. The [canonical tool-output contract](2026-07-20-canonical-tool-output-contract.md) supersedes that authoring shape: every tool now returns one schema-declared JSON value, `output.render(args, value)` derives model-facing blocks, and optional `output.presentationMeta(args, value)` derives replayable UI data. -```ts ignore-check -type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown } -``` +`presentationMeta` is tool-owned `JsonValue` that the core persists without interpreting its fields. `Session.append` validates it with the rest of the event, and replay passes the stored payload back to `presentResult`; presentations therefore reproduce without I/O or recomputation. The canonical value itself remains execution-local and is not added to the session format. -`meta` is tool-owned `unknown` that the core persists without interpretation. `Session.append` rejects non-JSON values, and replay passes the stored payload back to `presentResult`; presentations therefore reproduce without I/O or recomputation. Runtime validation avoids adding a shared serializable-value dependency to the tools core. - -This is the general shape ("a tool attaches durable result presentation"), not an fs-specific one — any tool can use it. +This remains the general shape ("a tool projects durable result presentation"), not an fs-specific one—any tool can use it. ### 2. The tool computes the hunk; the backend returns before/after (fs) Per the [capability-seam split](2026-06-13-capability-seams.md), the storage backend returns only **storage facts** and the model-facing tool owns **presentation**: - `dsh-fs` widens `FsEditOutcome` with `{ before: string; after: string }` and `FsWriteOutcome` with `{ before: string | null; after: string }` (`before: null` ⇒ a create, or an existing-but-undiffable binary/non-UTF-8 file). The local backend already holds both texts at write time; it returns them as raw LF-normalized text, with **no diff/UI concept** entering the seam. -- `dsh-tool-fs` stores contextual hunks in `meta: { diffs: FileDiff[] }`. Successful mutations always complete with a diff card because ACP result content replaces the pending card: creates or unchanged overwrites fall back to an args-derived whole-file diff, while edits use applied hunks. Failed mutations carry no diff metadata and render their error normally. +- `dsh-tool-fs` returns canonical before/after mutation facts and projects contextual hunks as `meta: { diffs: FileDiff[] }`. Successful mutations always complete with a diff card because ACP result content replaces the pending card: creates or unchanged overwrites fall back to an args-derived whole-file diff, while edits use applied hunks. Failed mutations carry no diff metadata and render their error normally. ### 3. The bridge renders a `diff` result card @@ -43,7 +39,7 @@ Per the [capability-seam split](2026-06-13-capability-seams.md), the storage bac ## Consequences -`tool/result` events may now carry a tool-private `meta` payload — part of the on-disk vocabulary, runtime-gated to JSON by `Session.append` — and any tool can attach durable result presentation without another core change. The diff card reproduces on session reload and snapshot replay for free: it is read back from the log, never recomputed. The costs: an overwrite holds both the prior and new text in memory to compute a UI-only hunk (`TODO(overwrite-diff-bound)`), and `dsh-tool-fs` carries a small, well-known runtime dependency. +`tool/result` events carry a tool-private `meta` payload—part of the on-disk vocabulary, runtime-gated to JSON by `Session.append`—and any tool can project durable result presentation without another core change. The diff card reproduces on session reload and snapshot replay for free: it is read back from the log, never recomputed. The costs: an overwrite holds both the prior and new text in memory to compute a UI-only hunk (`TODO(overwrite-diff-bound)`), and `dsh-tool-fs` carries a small, well-known runtime dependency. ## Non-goals diff --git a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md index f90e653a7c..2962f1006e 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md +++ b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md @@ -150,6 +150,6 @@ The formatter hook is deliberately small: a tool turns a `RetentionNotice` into **Put `read` windowing behind `ItemRetainer`.** Rejected for v1: `read` is the only current window consumer, and its semantics are file pagination rather than generic retention. A single `Omitted` count cannot represent both sides of a line window, and `read` also carries `totalLines`, offset-range errors, per-line preview truncation, and a byte cap over selected output. Keeping `read-render` tool-owned avoids growing the shared library around one special case. -**Make truncation part of `ToolExecutionResult`.** Rejected: the tool registry would have to understand tool-specific recovery guidance, grouping, line numbering, exit status, and provider semantics. Retention is a library used before a tool returns `ContentBlock[]`; the model-facing result remains tool-owned. +**Make truncation part of `ToolExecutionResult`.** Rejected: the tool registry would have to understand tool-specific recovery guidance, grouping, line numbering, exit status, and provider semantics. Retention is a library used by a tool's Native renderer; the model-facing projection remains tool-owned while the [canonical value](2026-07-20-canonical-tool-output-contract.md) may retain the complete acquired result. **Expose limits in every model-facing tool schema.** Rejected as the default: Claude Code's grep exposes `head_limit` / `offset`, but this harness keeps routine budgets as deployment config unless the model genuinely needs pagination control. A future read-like continuation field can be added per tool; it does not belong in the shared retention primitive. diff --git a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md index 040a6c2fdc..df0608cb34 100644 --- a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md +++ b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md @@ -61,7 +61,10 @@ function toolTimeoutResult(timeoutMs: number): ToolExecutionResult { return { content: [{ type: 'text', text: `Error: tool call timed out after ${timeoutMs}ms` }], isError: true, - error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' }, + error: { + message: `tool call timed out after ${timeoutMs}ms`, + info: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' }, + }, } } ``` diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md index a9197de179..e2df9a902b 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md @@ -8,7 +8,7 @@ Tool outputs need bounded model-facing previews, but some oversized results are Before this change the behavior was uneven. `dsh-bash-local` already writes complete stdout/stderr streams to private temp spill files when its in-memory tail overflows, but ordinary text tool results were returned inline unless the tool hand-rolled its own cap. The [tool result retention library](2026-07-06-tool-result-retention-library.md) owns preview mechanics, but it does not own storage or an execution-pipeline policy that applies those mechanics to final tool results. -The shape matches the timeout policy design: a tool author normally returns the text result, and a policy plugin enforces the deployment's default context budget. Tool-specific early spill remains possible later for outputs that do not survive to the final `ToolExecutionResult`; the first cut proves the default final-result path. +The shape matches the timeout policy design: a tool author declares a canonical value plus Native renderer, and a policy plugin enforces the deployment's default context budget on rendered content. Tool-specific early spill remains possible for provider acquisition bounds; tool-owned surface spill may retain a complete acquired canonical value while replacing only presentation. The [canonical tool-output contract](2026-07-20-canonical-tool-output-contract.md) owns that split. ## Decision @@ -97,9 +97,13 @@ The policy skips `read` to avoid a circular `read -> spill file -> read again` l ```ts ignore-check ctx.tools.register(defineTool({ name: 'web_fetch', + output: { + schema: WEB_FETCH_RESULT_SCHEMA, + render: (_args, value) => [{ type: 'text', text: formatFetchOutput(value) }], + }, async execute(args, exec) { const result = await ctx.web.fetch({ url: args.url }, exec.signal ? { signal: exec.signal } : undefined) - return [{ type: 'text', text: formatFetchOutput(result) }] + return result }, })) ``` diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml new file mode 100644 index 0000000000..d76de6a74e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml @@ -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-canonical-tool-output-contract.md: 226ca3274e08e2d46d29075ee412d4945fda753a +2026-07-20-canonical-tool-output-contract.zh.md: c5c5e46e267dd3d0795df7fb6761e867e52b5b2b diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md new file mode 100644 index 0000000000..226ca3274e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md @@ -0,0 +1,76 @@ +# Agent Note: Canonical tool output contract + +Status: implemented + +English | [中文](2026-07-20-canonical-tool-output-contract.zh.md) + +## Problem + +Tool bodies previously authored model-facing `ContentBlock[]` directly, optionally wrapping it with opaque `meta`. Native function calling therefore had a usable human projection, but a programmatic caller had no stable domain value: Code Mode flattened the blocks back into a string, dynamic tools repeated the content shape, and policy could replace presentation without any way to distinguish that change from replacing the operation's result. Several capability seams already returned richer provider values only to discard them at their model-facing tool boundary. + +The durable session contract made that presentation authoritative for replay, but persisting every rich intermediate value would enlarge logs, expose implementation data to compaction and migration, and incorrectly turn an execution-local API into session format. The foundation instead needs one typed value during execution and an explicit projection into the existing durable/model-facing content. + +## Decision + +Every tool declares a mandatory canonical output and returns only the value described by it: + +```ts ignore-check +output: { + schema: OutputSchema + render(args, value): ContentBlock[] + presentationMeta?(args, value): JsonValue +} +``` + +`defineTool` infers the body return and both projectors from the unified `ValueSchemaSpec`. Raw and dynamic definitions provide the compiled `JsonSchemaNode` form. Registration rejects a missing declaration or unsupported raw schema; there is no content-return compatibility path. + +For each successful dispatch the registry snapshots the returned value as lossless `JsonValue`, validates it against `output.schema`, deep-freezes it, then invokes the pure renderer and, for a direct surface call, the optional metadata projector. Renderer, projector, schema, or lossless-JSON failures are contained as ordinary `ToolOutputError` results. An around `tools/execute` wrapper receives and returns the canonical success/failure union; a wrapper-authored success is normalized again through the resolved tool's output declaration instead of trusting independently authored content. + +```ts ignore-check +type ToolExecutionResult = + | { isError: false; value: JsonValue; content: ContentBlock[]; meta?: JsonValue; additionalContexts?: HookContext[] } + | { isError: true; error: { message: string; info?: { name: string; code: string } }; content: ContentBlock[]; meta?: JsonValue; additionalContexts?: HookContext[] } +``` + +`tools/post-execute` has two mutually exclusive successful projections. Replacing `content` changes only Native/model presentation and preserves the canonical value and metadata. Replacing `value` revalidates the replacement and recomputes both presentation projections. A block removes the value and becomes a failure. Content replacement is therefore not a confidentiality mechanism: policy that must prevent programmatic access blocks the call or replaces the value. + +Canonical values are execution-local. The agent loop persists `tool/result` with only `content`, `error`, and `meta`; Code Mode's `tool/code-dispatch` persists only its bounded summary. Neither event stores the intermediate value, so replay reproduces presentation but cannot reconstruct the programmatic result. `presentationMeta` is computed only for a direct surface call, including the outer `run_code`; a nested Code dispatch gets no metadata or result card. Generic and tool-owned spill projections similarly skip nested dispatches, whose canonical value never enters model context. + +The first-party tools preserve their existing Native text while returning domain DTOs: + +| Tool family | Canonical value | +|---|---| +| `read` | `{ path, offset, lines: [{ number, text }], totalLines }` | +| `write` | `{ path, operation: "create" | "update", before: string | null, after }` | +| `edit` | `{ path, before, after }` | +| `glob` | `{ paths: string[] }` | +| `grep` | `{ matches: [{ path, lineNumber, line }] }` | +| `web_search` / `web_fetch` | The normalized `WebSearchResult` / `WebFetchResult` | +| `bash` | `{ kind: "background", taskId }` or `{ kind: "foreground" } & BashRunResult` | +| `task_output` / `task_list` / `task_kill` | Public task snapshots without owner or notification bookkeeping | +| `subagent` | Background task handle or `{ kind: "foreground", runId, output: JsonValue[] }` | +| `workflow` / `ralph` | `{ runId, agentsStarted, result: JsonValue }` | +| `skill` | `{ name, provider, resourceBase?, content }` | +| `todo_write` | `{ todos, counts }` | +| `ask_user_question` | `{ answers: [{ id, selected, custom? }] }` | +| `cordis_inspect` / `cordis_mount` / `cordis_unmount` | Inspection text or typed dynamic-mount handles | +| `structured_output` | `{ recorded: true }` | +| `run_code` | `{ logs: string[], result?: JsonValue }` | + +Provider and executor acquisition limits remain real limits on the canonical value. Formatting-only limits belong in `render`; `glob` and `grep`, for example, keep every acquired item in `value` while their Native projection retains and best-effort spills the configured first page. Filesystem mutations derive replayable diff metadata from `args` and the canonical before/after value rather than returning UI state from the body. + +MCP bridges preserve protocol blocks through `McpResult<{...}> = { content: JsonValue[]; structuredContent? }`. An advertised `outputSchema` is enforced when it belongs to the supported raw subset; unsupported schemas fall back to `JsonValue` rather than pretending to validate them. Native rendering still uses the existing MCP-to-`ContentBlock` projection, and MCP `isError` becomes a failed tool result. + +## Alternatives considered + +- **Return rendered text to Code Mode:** rejected because callers would continue scraping prose for task ids, mount ids, paths, and structured provider results. +- **Persist canonical values on `tool/result`:** rejected because nested execution values are not model history, need not survive replay, and would create a session-format and storage commitment unrelated to Native reconstruction. +- **Let tools return both value and content:** rejected because two author-owned results can disagree and policy cannot state which one is authoritative. The renderer makes presentation a deterministic projection of the validated value. +- **Treat content replacement as value redaction:** rejected because presentation and programmatic access are different consumers; hiding only the former would create a false security boundary. +- **Require object-rooted tool outputs:** rejected because scalar, array, and null results are legitimate JSON APIs. Object-rooting remains a consumer rule for caller-defined subagent/workflow structured output. + +## Consequences + +Native and replay behavior remains content-first and byte-compatible, while execution-time callers can use a validated domain value without parsing that content. Failures have one required message plus optional internal class/code information, successful and failed outcomes are discriminated, and a failed result can never promise a value. Tool authors must design the value and Native projection together; the extra declaration is intentional because it prevents accidental programmatic contracts from being inferred from prose. + +Intermediate values remain bounded only by the producing capability and process memory. Their omission from the log means replay cannot recover them, and a content-only post policy does not hide them. These are explicit properties of the execution-local contract, not accidental gaps. diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md new file mode 100644 index 0000000000..c5c5e46e26 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md @@ -0,0 +1,76 @@ +# Agent Note:规范工具输出契约 + +Status: implemented + +[English](2026-07-20-canonical-tool-output-contract.md) | 中文 + +## 问题 + +工具主体过去直接编写面向模型的 `ContentBlock[]`,并可选择将其与不透明的 `meta` 包装在一起。因此,Native 模式的 Function Calling(函数调用)虽然拥有可供人阅读的投影,但程序化调用方没有稳定的领域值:Code Mode 会将内容块重新展平为字符串,动态工具会重复定义内容形态,策略也可以替换展示内容,却无法区分这项变更究竟是替换展示,还是替换操作结果。多个能力 seam 已经返回了信息更丰富的提供方值,却又在面向模型的工具边界丢弃这些值。 + +持久会话契约将这份展示内容视为回放时的权威来源,但如果持久化每一个信息丰富的中间值,就会扩大日志、使实现数据进入压缩和迁移流程,还会错误地把执行期本地 API 变成会话格式的一部分。因此,系统底层需要在执行期间保留一个类型化值,并显式将其投影为现有的持久化内容和模型可见内容。 + +## 决策 + +每个工具都必须声明规范输出,并且只能返回该声明描述的值: + +```ts ignore-check +output: { + schema: OutputSchema + render(args, value): ContentBlock[] + presentationMeta?(args, value): JsonValue +} +``` + +`defineTool` 从统一的 `ValueSchemaSpec` 推导工具主体返回值和两个投影器的类型。原始定义和动态定义则提供编译后的 `JsonSchemaNode` 形式。注册时会拒绝缺失输出声明或采用不受支持原始 schema 的定义,不提供兼容旧式内容返回值的路径。 + +每次成功分发时,注册表会将返回值快照为无损 `JsonValue`,依据 `output.schema` 校验并深度冻结,然后调用纯渲染器;对于直接的外层调用,还会调用可选的元数据投影器。渲染器、投影器、schema 或无损 JSON 处理失败都会被收敛为普通 `ToolOutputError` 结果。around `tools/execute` 包装层接收并返回规范的成功/失败联合;包装层自行产生的成功结果会再次通过已解析工具的输出声明完成归一化,而不会信任其独立编写的内容。 + +```ts ignore-check +type ToolExecutionResult = + | { isError: false; value: JsonValue; content: ContentBlock[]; meta?: JsonValue; additionalContexts?: HookContext[] } + | { isError: true; error: { message: string; info?: { name: string; code: string } }; content: ContentBlock[]; meta?: JsonValue; additionalContexts?: HookContext[] } +``` + +`tools/post-execute` 为成功结果提供两种互斥的投影方式。替换 `content` 只改变 Native/模型展示,并保留规范值和元数据。替换 `value` 会重新校验替代值,并重新计算两份展示投影。阻止操作会移除值并转为失败。因此,替换内容并不是保密机制:必须阻止程序化访问的策略,应当阻止调用或替换值。 + +规范值仅存在于执行期间。agent loop(智能体循环)持久化的 `tool/result` 只包含 `content`、`error` 和 `meta`;Code Mode 的 `tool/code-dispatch` 只持久化其有界摘要。两个事件都不存储中间值,因此回放可以重现展示,却无法重建程序化结果。系统只会为直接的外层调用计算 `presentationMeta`,其中包括外层 `run_code`;嵌套 Code 分发没有元数据或结果卡片。通用以及工具自有的输出落盘投影同样跳过嵌套分发,因为它们的规范值永远不会进入模型上下文。 + +第一方工具在保持现有 Native 文本不变的同时返回领域 DTO: + +| 工具系列 | 规范值 | +|---|---| +| `read` | `{ path, offset, lines: [{ number, text }], totalLines }` | +| `write` | `{ path, operation: "create" | "update", before: string | null, after }` | +| `edit` | `{ path, before, after }` | +| `glob` | `{ paths: string[] }` | +| `grep` | `{ matches: [{ path, lineNumber, line }] }` | +| `web_search` / `web_fetch` | 归一化后的 `WebSearchResult` / `WebFetchResult` | +| `bash` | `{ kind: "background", taskId }` 或 `{ kind: "foreground" } & BashRunResult` | +| `task_output` / `task_list` / `task_kill` | 不含所有者或通知账务字段的公开任务快照 | +| `subagent` | 后台任务句柄或 `{ kind: "foreground", runId, output: JsonValue[] }` | +| `workflow` / `ralph` | `{ runId, agentsStarted, result: JsonValue }` | +| `skill` | `{ name, provider, resourceBase?, content }` | +| `todo_write` | `{ todos, counts }` | +| `ask_user_question` | `{ answers: [{ id, selected, custom? }] }` | +| `cordis_inspect` / `cordis_mount` / `cordis_unmount` | 检查文本或类型化的动态挂载句柄 | +| `structured_output` | `{ recorded: true }` | +| `run_code` | `{ logs: string[], result?: JsonValue }` | + +提供方和执行器的采集上限仍会实际限制规范值。仅用于格式化的限制归 `render` 所有;例如,`glob` 和 `grep` 会在 `value` 中保留所有已采集项,而其 Native 投影仍只保留配置指定的第一页,并尽力将完整展示内容写入落盘文件。文件系统变更工具根据 `args` 和规范的变更前/后值推导可回放的 diff 元数据,不再由工具主体返回 UI 状态。 + +MCP 桥接层通过 `McpResult<{...}> = { content: JsonValue[]; structuredContent? }` 保留协议内容块。当公布的 `outputSchema` 属于受支持的原始子集时,系统会强制校验;不受支持的 schema 则回退为 `JsonValue`,而不会假装已完成校验。Native 渲染仍使用现有的 MCP 到 `ContentBlock` 投影,MCP `isError` 则会变为失败的工具结果。 + +## 备选方案 + +- **向 Code Mode 返回渲染后的文本:**不予采纳。调用方仍需从自然语言中提取 task id、挂载 id、路径和结构化提供方结果。 +- **在 `tool/result` 上持久化规范值:**不予采纳。嵌套执行值不属于模型历史记录,无需在回放后继续存在;持久化还会引入与 Native 重建无关的会话格式和存储承诺。 +- **允许工具同时返回值和内容:**不予采纳。由作者分别维护的两份结果可能互相矛盾,策略也无法说明哪一份才是权威结果。渲染器会根据已校验值确定性地产生展示。 +- **将内容替换视为值脱敏:**不予采纳。展示内容和程序化访问面向不同消费方;只隐藏前者会制造虚假的安全边界。 +- **要求工具输出必须以对象为根:**不予采纳。标量、数组和 null 结果都是合理的 JSON API。只有由调用方定义的 subagent/工作流结构化输出仍受消费方的对象根规则约束。 + +## 影响 + +Native 和回放行为仍以内容为先,并保持逐字节兼容;执行期调用方则无需解析内容,即可使用经过校验的领域值。失败结果必须包含消息,并可选择附加内部类名/代码信息;成功与失败结果由判别字段区分,失败结果绝不会承诺存在值。工具作者必须一并设计值及其 Native 投影;增加这项声明是有意为之,因为它避免从自然语言内容意外推导出程序化契约。 + +中间值只受产生它们的能力和进程内存限制。日志不包含这些值,因此回放无法恢复;仅处理内容的 post 策略也无法隐藏这些值。这些都是执行期本地契约的明确属性,并非意外缺口。 diff --git a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md index a318956ddf..59bf9be768 100644 --- a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md +++ b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md @@ -68,7 +68,7 @@ The first pass rejects Codex-style patch grammars and multi-mode edit APIs. It u ## Result shape -The first implementation returns `ContentBlock[]` through the existing `ToolDefinition.execute()` contract. `ctx.fs` returns structured filesystem results and owns file-state recording/refreshing; `tool-fs` formats those results into the model projection. +The first implementation formatted `ContentBlock[]` in `execute`. The [canonical tool-output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) now keeps `ctx.fs` result facts as the tool's validated value and derives the same model text through `output.render`; file-state recording/refreshing remains on `ctx.fs`. Default native projections: diff --git a/.agents/notes/implemented/feature/2026-06-30-interception-seams.md b/.agents/notes/implemented/feature/2026-06-30-interception-seams.md index 2535c1c564..82102efa99 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.md +++ b/.agents/notes/implemented/feature/2026-06-30-interception-seams.md @@ -24,11 +24,11 @@ Every call follows `tools/pre-execute` → guards → `tools/execute` → dispat - **`tools/pre-execute`** is the extensible waterfall gate. Its `PreToolDecision` allows, denies, or asks. Deny skips `tools/execute` and core dispatch. Ask resolves through the optional approval seam: only `allowed-once` continues through guards and dispatch; rejection, cancellation, an unavailable channel, a missing approval service, or an agent-less call becomes a normalized denial. Every outcome still reaches post-policy and final observers. - **`ctx.tools.guard()`** installs synchronous scope-aware policy after the whole pre-execute waterfall. A guard may deny or abstain, never force-allow, so listener ordering cannot resurrect an operation that a final invariant forbids. -- **`tools/execute`** is the around-dispatch waterfall for timeout, retry, and metrics plugins. A wrapper delegates to core dispatch with `next()`, may add, replace, or remove only `exec.signal` before doing so, and receives the already-normalized result of a thrown or unknown tool; returning its own valid result short-circuits dispatch. -- **`tools/post-execute`** is the inspect/transform waterfall. Its `PostToolDecision` accepts, blocks with feedback, optionally replaces content, or attaches `additionalContexts`. The returned decision is the supported transform channel; after the waterfall, the registry materializes the complete outcome once before final observation. +- **`tools/execute`** is the around-dispatch waterfall for timeout, retry, and metrics plugins. A wrapper delegates to core dispatch with `next()`, may add, replace, or remove only `exec.signal` before doing so, and receives the already-normalized canonical success/failure result of a thrown or unknown tool; a wrapper-authored success is re-normalized through the resolved output declaration. +- **`tools/post-execute`** is the inspect/transform waterfall. Its `PostToolDecision` accepts, blocks with feedback, replaces either presentation content or canonical value, or attaches `additionalContexts`. Value replacement revalidates and recomputes presentation; content replacement preserves programmatic value and is not a confidentiality boundary. The returned decision is the supported transform channel; after the waterfall, the registry materializes the complete outcome once before final observation. - **`tools/result`** is the synchronous contained notification after every transform, lossless-JSON materialization, and the outer error boundary. It receives the same frozen execution identity and an immutable snapshot of the authoritative result; observer failures are contained per listener and cannot change or reject `ToolRegistry.execute()`'s returned outcome. -Core dispatch and the tool body sit inside normalization boundaries, so tool, listener, malformed-result, non-JSON result, and identity-shape failures resolve as JSON-safe `isError` results rather than escaping the turn. A post-execute listener can therefore inspect a thrown tool, and a final observer sees exactly what the caller receives and the session log can persist. +Core dispatch and the tool body sit inside normalization boundaries, so tool, listener, invalid canonical value, renderer/projector, non-JSON presentation, and identity-shape failures resolve as JSON-safe `isError` results rather than escaping the turn. A post-execute listener can therefore inspect a thrown tool, and a final observer sees the execution-local canonical value beside exactly the presentation fields the session log can persist. The [canonical tool-output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/projection and durability rules. **`TurnEndReason.rejected`** (`dsh-session`): a zero-step turn whose claimed prompt was blocked by `prompt-submit`. diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md index 2038b2b719..429102aeee 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md @@ -30,7 +30,7 @@ Mount code runs as an async-function body in a fresh vm realm. Its documented su Sandbox globals are deliberately small: a tagged write-through `console` (`[cordis:] …` on the host stdout/stderr, so a listener that fires long after the mount call still lands somewhere the user sees), the `harness.defineTool` / `harness.registerTool` registration pair, the encoding primitives fresh vm contexts lack (`btoa`/`atob` as host closures over `Buffer` — a sanctioned exception, `Buffer` itself is never exposed — plus `TextEncoder`/`TextDecoder`), and callable traps over the withheld Node APIs (`require`, `setTimeout`/`setInterval`/`setImmediate`/`clearTimeout`/`clearInterval`, `fetch`) that throw a redirect naming the cordis alternative. Only function-shaped globals are trapped; `process` and `Buffer` stay `undefined` so a `typeof` feature probe stays inert rather than detonating a throwing accessor. -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` rebuilds the output schema/projectors in the host realm, snapshots the body value as host-owned JSON, and lets the registry enforce the [canonical tool-output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) before observation. 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 `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. diff --git a/docs/architecture.md b/docs/architecture.md index 0c3e55f269..f51059e63d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -97,7 +97,8 @@ forever: exclusive -> one-call barrier parallel -> rolling pool, <= maxParallelToolCalls in flight; reclassify before start each start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute - each model-order result -> ordered tools/post-execute -> 'tool/result' + body value -> validate/snapshot -> Native/meta projection + each model-order result -> ordered tools/post-execute -> projected 'tool/result' append accepted tool-batch context after all recorded results, then steering agent/post-step 'step/end' @@ -110,7 +111,7 @@ forever: Each step assembles ordered prompt sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn. `dsh-system-prompt` owns the harness identity and default persona, which an agent scope may shadow. The loop supplies `model` and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). -Tool-time context—including async `agent.inject()` notices and post-tool `additionalContexts`—settles after results. Steering drains; before signal closure, `agent/post-step` observes durable output, results, context, and steering. Leftovers queue. Terminal `agent/turn-stop` runs after continuation and steering folding, remains authoritative through close/flush, and discards later steering while preserving queued prompts. +Tool success separates an execution-local canonical JSON value from Native projections; post-policy replaces one projection or blocks, and the loop persists only projections ([contract](../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md)). Tool-time context—including async `agent.inject()` notices and post-tool `additionalContexts`—settles after results. Steering drains; before signal closure, `agent/post-step` observes durable output, results, context, and steering. Leftovers queue. Terminal `agent/turn-stop` runs after continuation and steering folding, remains authoritative through close/flush, and discards later steering while preserving queued prompts. Pruning precedes summaries; overflow retries require durable progress. Bounded transient retries compose on `agent/request-error`; cancellation wins ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)). diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 44e27bb572..47a9926575 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -27,7 +27,7 @@ export interface AcpConfig { Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/ui/acp/src/index.ts:247`](../packages/ui/acp/src/index.ts) +Source: [`packages/ui/acp/src/index.ts:248`](../packages/ui/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-demo` @@ -666,7 +666,7 @@ export interface StreamableHttpConfig { } ``` -Source: [`packages/mcp/mcp-client/src/index.ts:91`](../packages/mcp/mcp-client/src/index.ts) +Source: [`packages/mcp/mcp-client/src/index.ts:93`](../packages/mcp/mcp-client/src/index.ts) ## `@deepseek-ai/dsh-permission` @@ -923,7 +923,7 @@ export interface Config { } ``` -Source: [`packages/spill/spill-policy/src/index.ts:45`](../packages/spill/spill-policy/src/index.ts) +Source: [`packages/spill/spill-policy/src/index.ts:50`](../packages/spill/spill-policy/src/index.ts) ## `@deepseek-ai/dsh-subagent-acp` @@ -1157,7 +1157,7 @@ export interface Config { } ``` -Source: [`packages/workflow/tool-ralph/src/index.ts:22`](../packages/workflow/tool-ralph/src/index.ts) +Source: [`packages/workflow/tool-ralph/src/index.ts:23`](../packages/workflow/tool-ralph/src/index.ts) ## `@deepseek-ai/dsh-tool-skill` @@ -1227,7 +1227,7 @@ export interface Config { Depends on: [`AgentOptions`](core-data-structures/core.md) -Source: [`packages/subagent/tool-subagent/src/index.ts:23`](../packages/subagent/tool-subagent/src/index.ts) +Source: [`packages/subagent/tool-subagent/src/index.ts:24`](../packages/subagent/tool-subagent/src/index.ts) ## `@deepseek-ai/dsh-tool-tasks` @@ -1281,7 +1281,7 @@ export interface Config { } ``` -Source: [`packages/workflow/tool-workflow/src/index.ts:26`](../packages/workflow/tool-workflow/src/index.ts) +Source: [`packages/workflow/tool-workflow/src/index.ts:27`](../packages/workflow/tool-workflow/src/index.ts) ## `@deepseek-ai/dsh-tools` @@ -1303,7 +1303,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:397`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:448`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-tui` diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml index 288ee96446..eb536bb063 100644 --- a/docs/cookbook/adding-a-tool.i18n.yaml +++ b/docs/cookbook/adding-a-tool.i18n.yaml @@ -2,5 +2,5 @@ # 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 -adding-a-tool.md: 94cb4fcfa9a0155fd57ef18f9a855a264c2eff84 -adding-a-tool.zh.md: 637dc3381765e57c2d0420164ac13fde7fb590fe +adding-a-tool.md: f94ddfaa9df53c0ee4596d683e676baae6bd85b2 +adding-a-tool.zh.md: 915dc8250c2bcfc490483f87c71e725b1f92f635 diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index 94cb4fcfa9..f94ddfaa9d 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -22,10 +22,14 @@ export function apply(ctx: Context) { path: { type: 'string', required: true, description: 'Absolute path' }, limit: { type: 'number' }, // optional by default }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, async execute(args, exec) { // args is TYPED from the schema: { path: string; limit?: number } // exec carries immutable identity + token; signal is the operational field - return [{ type: 'text', text: await readFile(args.path, 'utf8') }] + return readFile(args.path, 'utf8') }, })) } @@ -38,9 +42,10 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool (w - **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. - **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. +- **Declare and return one canonical JSON value.** `output.schema` uses `ValueSchemaSpec` and may have an object, array, scalar, or null root. `execute` returns only the inferred value; the registry snapshots it as lossless JSON, validates it, freezes it, and passes it to `output.render(args, value)`. Do not return content blocks from the body or make callers parse prose for ids and fields. +- **Throwing or returning an invalid value means `isError`.** The registry catches throws and contains schema, renderer, metadata-projector, and lossless-JSON failures before observers run. Throw for infrastructure failures. Represent a successful domain outcome in the canonical value even when its Native renderer explains a non-ideal state, such as a non-zero process exit. - **Honor `exec.signal`.** Cancel in-flight work when it fires. -- **Attach durable card data with `meta` (optional).** `execute` may return `{ content, meta }` instead of a bare `ContentBlock[]` — `meta` is a JSON-serializable payload the core treats as opaque, persisted on the `tool/result` event and handed back to your `presentResult` (so a card that needs more than `args`, like `write`/`edit`'s applied-hunk diff, survives a session replay). Keep UI-only data here, never in the model-facing `content`. +- **Project durable card data with `presentationMeta` (optional).** `output.presentationMeta(args, value)` derives replayable JSON from the same canonical value. The core persists it on `tool/result` and hands it to `presentResult`, so a card that needs result-time facts—such as `write`/`edit` applied hunks—survives replay without persisting the canonical value. The projector is skipped for nested Code dispatches because they have no cards. - **Use `exec.agent` for async notifications.** `agent.inject(content, {source: {kind: 'plugin', plugin: ''}})` appends durable context the NEXT model request sees — it is not a wake-up (an idle agent stays idle). Guard against disposed agents (try/catch). ## Long-running work @@ -51,7 +56,7 @@ The producer supplies synchronous `cancel`, non-rejecting `done` that settles af ## Execution policy and observation -Prefer not to build deployment policy into the tool. Use `tools/pre-execute` for extensible allow/deny/ask policy (the [permission-gate example](extension-cookbook.md#a-hook-plugin-permission-gate-example)), `ctx.tools.guard()` for a final monotonic deny that later listeners cannot undo, `tools/execute` to wrap core dispatch with a deadline/retry/metrics scope, `tools/post-execute` to transform or attach model-facing context, and `tools/result` to observe the immutable normalized outcome without changing it. A sandboxing implementation can also sit behind the tool's executor capability seam; the exact contracts are in the [`dsh-tools` README](../../packages/core/tools/README.md#extension-points). +Prefer not to build deployment policy into the tool. Use `tools/pre-execute` for extensible allow/deny/ask policy (the [permission-gate example](extension-cookbook.md#a-hook-plugin-permission-gate-example)), `ctx.tools.guard()` for a final monotonic deny that later listeners cannot undo, `tools/execute` to wrap canonical dispatch with a deadline/retry/metrics scope, `tools/post-execute` to replace either presentation content or the canonical value, block, or attach model-facing context, and `tools/result` to observe the immutable normalized outcome. A content replacement leaves programmatic access to `value` intact; confidentiality policy blocks or replaces the value. A sandboxing implementation can also sit behind the tool's executor capability seam; the exact contracts are in the [`dsh-tools` README](../../packages/core/tools/README.md#extension-points). ## Code Mode reaches your tool for free @@ -59,7 +64,7 @@ In [Code Mode](../../packages/core/tools/README.md), every visible registered to ## How your tool renders in an editor (ACP presentation) -Your tool's `execute` returns model-facing content; its **editor card** is a separate, optional concern you declare with two pure display methods on the `defineTool` options. Design this alongside `execute`, not after — an editor (Zed, over the ACP bridge) shows the card, and a tool with no presentation falls back to a bland generic card (title = tool name, raw args as input). +Your tool's `output.render` returns model-facing content; its **editor card** is a separate concern declared through pure presentation projections and optional `presentCall` / `presentResult` methods. Design these alongside the canonical value—an editor (Zed, over the ACP bridge) shows the card, and a tool with no UI presentation falls back to a generic card (title = tool name, raw args as input). Both methods return a **`card`-tagged render intent** — pick the card kind that matches what your tool does: @@ -70,16 +75,16 @@ Both methods return a **`card`-tagged render intent** — pick the card kind tha - `presentResult(args, { content, isError, meta? })` returns the completed card: - `generic` supplies an optional title and content. - `terminal` supplies raw output and optional exit metadata; the bridge renders the capability-specific or fenced fallback view. - - `diff` supplies applied hunks, often carried in persisted `result.meta` so replay reproduces them. Mutation tools keep a diff result because an ACP update replaces the pending card's content. + - `diff` supplies applied hunks, often derived by `output.presentationMeta` and carried in persisted `result.meta` so replay reproduces them. Mutation tools keep a diff result because an ACP update replaces the pending card's content. Hard rules (they bite if broken): - **Purity.** These run on live streaming AND on session-log REPLAY, so they must be pure functions of `args` (+ the result) — NO I/O, NO reading session state, NO clock/random. A diff is derived from the args (`write` uses `oldText: null` because a call-time presenter has no prior file content); the BRIDGE, not the tool, fills the session cwd and relativizes a display-path title. If you find yourself wanting the file's old content or the working directory inside `presentCall`, stop — that belongs on the bridge or a future result-event shape, not the presenter. -- **UI-only formatting stays out of the model result.** A fenced ` ```console ` block, a diff, a relativized path — none of these may appear in what `execute` returns to the model; they live only in the presentation. (A `terminal` result view carries RAW `output`; the bridge adds the fences.) +- **UI-only formatting stays out of the model result.** A fenced ` ```console ` block, a diff, a relativized path—none of these belongs in the canonical value or Native content merely to serve an editor. `output.render` owns model-facing prose; `presentationMeta` plus the card presenters own replayable UI state. A `terminal` result view carries raw output and the bridge adds fences. - **`defineTool` soft-validates the display path.** A malformed/older logged arg shape makes the wrapper return `undefined` (a generic fallback) rather than throw — display must never crash a replay. The neutral vocabulary lives in `dsh-tools` (never import an ACP type into a tool); the ACP bridge maps each `card` to the wire. The design and the why are in [the render-intent-union Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md); `dsh-tool-fs` (generic/diff) and `dsh-tool-bash` (terminal) are the reference implementations. ## Tests every tool needs -Cover argument rejection, every result shape, and HMR disposal. For a side-effecting tool, drive the real tool through the agent loop with a scripted `MockAdapter` and assert its `tool/call` and `tool/result` session events. For an editor card, assert the exact `presentCall` and `presentResult` views and add an [ACP snapshot](../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) through the real bridge; a terminal card's scenario sets `terminalOutput: true` to exercise the capable-client path. +Cover argument rejection, every canonical value and Native rendering shape, output-schema rejection, and HMR disposal. For a side-effecting tool, drive the real tool through the agent loop with a scripted `MockAdapter` and assert its `tool/call` and projected `tool/result` session events; prove the canonical value itself is not persisted. For an editor card, assert the exact `presentCall` and `presentResult` views and add an [ACP snapshot](../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) through the real bridge; a terminal card's scenario sets `terminalOutput: true` to exercise the capable-client path. diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md index 637dc33817..915dc8250c 100644 --- a/docs/cookbook/adding-a-tool.zh.md +++ b/docs/cookbook/adding-a-tool.zh.md @@ -22,10 +22,14 @@ export function apply(ctx: Context) { path: { type: 'string', required: true, description: 'Absolute path' }, limit: { type: 'number' }, // optional by default }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, async execute(args, exec) { // args is TYPED from the schema: { path: string; limit?: number } // exec carries immutable identity + token; signal is the operational field - return [{ type: 'text', text: await readFile(args.path, 'utf8') }] + return readFile(args.path, 'utf8') }, })) } @@ -38,9 +42,10 @@ export function apply(ctx: Context) { - **参数已为你校验。** `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 其所属副作用并注册替代品;回调闭包内的可变状态仍是普通的插件状态。 - **执行身份受保护。** 注册表在一次递归遍历中将 `arguments` 物化为分离的无损 JSON,在策略开始前冻结该值,并分配一个不透明的 `exec.token`;`callId`、`name`、`arguments`、`agent`、`token` 以及可选的外层传输 `parent` token 在整个分发过程中保持不可变。`parent` 仅用于身份标识,不暴露活跃的外层执行。请将 `args` 视为只读输入。around-dispatch 包装器只能添加、替换或移除 `exec.signal`,以施加取消或截止时间。 -- **抛出异常或返回非 JSON 数据意味着 `isError`。** 注册表捕获异常,并在观察者运行前物化最终结果。格式错误或非 JSON 的结果变为 `{ isError: true }`,防止出现无法记录的活跃成功。基础设施故障请抛异常;当模型需要解读领域失败时,请在结果文本中报告。 +- **声明并返回一个规范 JSON 值。** `output.schema` 使用 `ValueSchemaSpec`,根可以是对象、数组、标量或 null。`execute` 只返回推导出的值;注册表将其快照为无损 JSON,完成校验和冻结后,再传给 `output.render(args, value)`。工具主体不要返回内容块,也不要迫使调用方从自然语言中解析 id 和字段。 +- **抛出异常或返回无效值意味着 `isError`。** 注册表会捕获异常,并在观察者运行前收敛 schema、渲染器、元数据投影器和无损 JSON 失败。基础设施故障请抛异常。成功的领域结果即使表示不理想的状态,也应写入规范值;其 Native 渲染器可以解释该状态,例如进程以非零状态退出。 - **遵守 `exec.signal`。** 信号触发时取消进行中的工作。 -- **使用 `meta` 附加持久化的卡片数据(可选)。** `execute` 可以返回 `{ content, meta }` 而非裸的 `ContentBlock[]`。`meta` 是 JSON 可序列化的载荷,核心将其视为不透明数据,持久化在 `tool/result` 事件上并回传给你的 `presentResult`(这样需要 `args` 之外信息的卡片——如 `write`/`edit` 的已应用 hunk diff——在会话回放中依然存活)。仅在此处放 UI 数据,绝不放入模型可见的 `content`。 +- **使用 `presentationMeta` 投影持久化的卡片数据(可选)。** `output.presentationMeta(args, value)` 从同一个规范值派生可回放的 JSON。核心将其持久化在 `tool/result` 上并传给 `presentResult`,因此需要结果期事实的卡片——例如 `write`/`edit` 的已应用 hunk——无需持久化规范值也能在回放中重现。嵌套 Code 分发没有卡片,因此会跳过该投影器。 - **使用 `exec.agent` 发送异步通知。** `agent.inject(content, {source: {kind: 'plugin', plugin: ''}})` 追加持久化上下文,下一次模型请求会看到它——这不是唤醒(空闲的 agent(智能体)保持空闲)。请防范已 dispose 的 agent(try/catch)。 ## 长时间运行的工作 @@ -51,7 +56,7 @@ producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 ## 执行策略与观测 -尽量不要把部署策略内建到工具中。使用 `tools/pre-execute` 实现可扩展的允许/拒绝/询问策略(见[权限门禁示例](extension-cookbook.md#a-hook-plugin-permission-gate-example));使用 `ctx.tools.guard()` 设置最终的单调拒绝(后续监听器无法撤销);使用 `tools/execute` 为核心分发包装截止时间/重试/指标作用域;使用 `tools/post-execute` 转换或附加模型可见的上下文;使用 `tools/result` 观测不可变的归一化结果而不改变它。沙箱实现也可以位于工具执行器的能力 seam 之后;确切契约见 [`dsh-tools` README](../../packages/core/tools/README.md#extension-points)。 +尽量不要把部署策略内建到工具中。使用 `tools/pre-execute` 实现可扩展的允许/拒绝/询问策略(见[权限门禁示例](extension-cookbook.md#a-hook-plugin-permission-gate-example));使用 `ctx.tools.guard()` 设置最终的单调拒绝,后续监听器无法撤销;使用 `tools/execute` 为规范分发包装截止时间/重试/指标作用域;使用 `tools/post-execute` 替换展示内容或规范值、阻止调用,或附加模型可见上下文;使用 `tools/result` 观测不可变的归一化结果而不改变它。替换内容不会阻止程序化访问 `value`;保密策略必须阻止调用或替换值。沙箱实现也可以位于工具执行器的能力 seam 之后;确切契约见 [`dsh-tools` README](../../packages/core/tools/README.md#extension-points)。 ## Code Mode 自动触达你的工具 @@ -59,7 +64,7 @@ producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 ## 工具在编辑器中的渲染方式(ACP 展示) -工具的 `execute` 返回模型可见的内容;其**编辑器卡片**是一个独立的、可选的关注点,通过 `defineTool` 选项中的两个纯展示方法声明。请与 `execute` 同步设计,而非事后补充——编辑器(如 Zed,通过 ACP(Agent Client Protocol)桥接)会展示该卡片,没有展示方法的工具回退为一个朴素的通用卡片(标题 = 工具名,原始 args 作为输入)。 +工具的 `output.render` 返回模型可见的内容;其**编辑器卡片**是另一项独立关注点,通过纯展示投影以及可选的 `presentCall`/`presentResult` 方法声明。请将这些内容与规范值一并设计:编辑器(如 Zed,通过 ACP(Agent Client Protocol)桥接)会展示该卡片,没有 UI 展示方法的工具则回退到通用卡片(标题 = 工具名,原始 args 作为输入)。 两个方法都返回一个 **`card` 标签的渲染意图**——选择与你的工具行为匹配的卡片类型: @@ -70,16 +75,16 @@ producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 - `presentResult(args, { content, isError, meta? })` 返回完成后的卡片: - `generic` 提供可选的标题和内容。 - `terminal` 提供原始输出和可选的退出元数据;桥接层渲染能力特定或围栏回退视图。 - - `diff` 提供已应用的 hunk,通常由持久化的 `result.meta` 携带,使回放能重现它们。变更类工具保留 diff 结果,因为 ACP 更新会替换 pending 卡片的内容。 + - `diff` 提供已应用的 hunk,通常由 `output.presentationMeta` 派生并通过持久化的 `result.meta` 携带,使回放能重现它们。变更类工具保留 diff 结果,因为 ACP 更新会替换 pending 卡片的内容。 硬性规则(违反会出问题): - **纯函数。** 这些方法在实时流式输出和会话日志回放时都会运行,因此必须是 `args`(加 result)的纯函数——不做 I/O、不读会话状态、不用时钟/随机数。diff 从 args 派生(`write` 使用 `oldText: null`,因为调用时的展示器没有文件先前内容);**桥接层**(而非工具)填充会话 cwd 并相对化展示路径标题。如果你发现自己想在 `presentCall` 内获取文件旧内容或工作目录,请停下——那属于桥接层或未来的 result-event 形态,不属于展示器。 -- **UI 格式不进入模型结果。** 围栏 ` ```console ` 块、diff、相对化路径——这些都不得出现在 `execute` 返回给模型的内容中;它们只存在于展示层。(`terminal` 结果视图携带原始 `output`;桥接层添加围栏。) +- **UI 格式不进入模型结果。** 围栏 ` ```console ` 块、diff、相对化路径均不应仅为服务编辑器而进入规范值或 Native 内容。`output.render` 负责模型可见的自然语言;`presentationMeta` 和卡片展示器负责可回放的 UI 状态。`terminal` 结果视图携带原始输出,由桥接层添加围栏。 - **`defineTool` 对展示路径做软校验。** 格式错误或旧版日志中的 arg 形态会使包装器返回 `undefined`(通用回退)而非抛异常——展示绝不能导致回放崩溃。 中性词汇定义在 `dsh-tools` 中(绝不在工具中导入 ACP 类型);ACP 桥接层将每个 `card` 映射到协议格式(wire format)。设计与原因见[渲染意图联合体 Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md);`dsh-tool-fs`(generic/diff)和 `dsh-tool-bash`(terminal)是参考实现。 ## 每个工具必须的测试 -覆盖参数拒绝、每种结果形态和 HMR dispose。对于有副作用的工具,使用脚本化的 `MockAdapter` 驱动真实工具通过 agent loop(智能体循环),并断言其 `tool/call` 和 `tool/result` 会话事件。对于编辑器卡片,断言 `presentCall` 和 `presentResult` 的精确视图,并通过真实桥接层添加一个 [ACP 快照](../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md);终端卡片的场景设置 `terminalOutput: true` 以覆盖 capable-client 路径。 +覆盖参数拒绝、每种规范值和 Native 渲染形态、输出 schema 拒绝以及 HMR dispose。对于有副作用的工具,使用脚本化的 `MockAdapter` 驱动真实工具通过 agent loop(智能体循环),并断言其 `tool/call` 和投影后的 `tool/result` 会话事件;同时证明规范值本身未被持久化。对于编辑器卡片,断言 `presentCall` 和 `presentResult` 的精确视图,并通过真实桥接层添加一个 [ACP 快照](../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md);终端卡片的场景设置 `terminalOutput: true` 以覆盖 capable-client 路径。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index cde1c4340b..2b995146ce 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -760,7 +760,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:131`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:135`](../../packages/core/tools/src/index.ts) ### `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) -Source: [`packages/core/tools/src/index.ts:104`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:108`](../../packages/core/tools/src/index.ts) ### `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) -Source: [`packages/core/tools/src/index.ts:113`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:117`](../../packages/core/tools/src/index.ts) ### `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) -Source: [`packages/core/tools/src/index.ts:95`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:99`](../../packages/core/tools/src/index.ts) ### `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) -Source: [`packages/core/tools/src/index.ts:121`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:125`](../../packages/core/tools/src/index.ts) ## `workflow/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 48feaa92a6..53180d3621 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1361,7 +1361,7 @@ async execute(exec: ToolExecutionInput): Promise 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:453`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:504`](../../packages/core/tools/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 8650688222..2df677790b 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -73,15 +73,24 @@ interface SessionEventMap { */ 'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string } /** - * A completed tool call's model-facing result, plus an optional tool-private - * `meta` presentation payload. `meta` is opaque to the core (`unknown` — the - * producing tool owns its shape and reads it back in `presentResult`) but MUST - * be JSON-serializable: `Session.append` runtime-validates all event data with - * `isJsonValue`, so a non-serializable `meta` is rejected at the source, and the - * durable log reproduces the identical card on replay. Absent unless the tool - * attaches one (e.g. `dsh-tool-fs` carries its result-time contextual diff here). + * A completed tool call's model-facing result, canonical failure detail, and + * optional tool-private `meta` presentation payload. `meta` is opaque to the + * core (the producing tool owns its shape and reads it back in `presentResult`) + * but MUST be JSON-serializable: `Session.append` runtime-validates all event + * data with `isJsonValue`, so a non-serializable `meta` is rejected at the + * source, and the durable log reproduces the identical card on replay. Absent + * unless the tool attaches one (e.g. `dsh-tool-fs` carries its result-time + * contextual diff here). */ - 'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown } + 'tool/result': { + turn: number + step: number + callId: CallId + content: ContentBlock[] + isError: boolean + error?: { message: string; info?: { name: string; code: string } } + meta?: JsonValue + } /** Steering content injected between steps of a running turn. */ 'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource } /** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */ diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 20a74e0190..6ec941d0df 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -6,12 +6,27 @@ Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index ## `ToolDefinition` — a registered tool -A `ToolSchema` (the model-facing fields) plus the `execute` function, host-only scheduler metadata, and optional UI presenters. The registry holds these; the loop dispatches calls through them. The registry's `schemas()` builds the model-facing `ToolSchema[]` by an explicit allowlist — `execute`/`timeoutMs`/`isConcurrencySafe`/`presentCall`/`presentResult` must never leak into a model request. +A `ToolSchema` (the model-facing fields) plus a mandatory canonical output declaration, the `execute` function, host-only scheduler metadata, and optional UI presenters. The registry holds these; the loop dispatches calls through them. The registry's `schemas()` builds the model-facing `ToolSchema[]` by an explicit allowlist — `output`/`execute`/`timeoutMs`/`isConcurrencySafe`/`presentCall`/`presentResult` must never leak into a model request. + +```ts type-equiv +/** Tool-owned canonical output contract used after the body returns a JSON value. */ +interface ToolOutputDefinition { + /** Raw supported JSON Schema enforced against every successful canonical value. */ + readonly schema: JsonSchemaNode + /** Pure projection from validated arguments and value to Native/model content. */ + render(args: unknown, value: JsonValue): ContentBlock[] + /** Pure replayable presentation projection, computed only for surface calls. */ + presentationMeta?(args: unknown, value: JsonValue): JsonValue +} +``` ```ts type-equiv /** A registered tool: its schema plus the execution function. */ interface ToolDefinition extends ToolSchema { - execute(args: unknown, exec: ToolRunContext): Promise + /** Mandatory canonical output declaration. */ + readonly output: ToolOutputDefinition + /** Execute the tool and return only its canonical lossless-JSON value. */ + execute(args: unknown, exec: ToolRunContext): Promise /** * Cooperative tool-call timeout budget in milliseconds. Omit for no deadline. * Enforced by `@deepseek-ai/dsh-timeout-policy` (a `tools/execute` wrapper); it @@ -46,7 +61,7 @@ interface ToolDefinition extends ToolSchema { presentCall?(args: unknown): ToolCallView | undefined /** * Optional: how to present the COMPLETED state, given the same `args` and the - * `result` (`execute`'s content + whether it errored). Returns a + * durable result projection (`content`, failure state, and optional `meta`). Returns a * {@link ToolResultView}, or `undefined` (or omit the method) to keep the * pending title and render the raw result content. Pure and side-effect-free * for the same replay reason. @@ -55,7 +70,7 @@ 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 the arguments, infers the body return from `output.schema`, and types both output projectors. ## The unified JSON-value schema DSL @@ -97,27 +112,28 @@ type ParameterSchemaSpec = Record * Infer the TypeScript value accepted by an author-facing value schema. * Output schemas may therefore infer object, array, scalar, or null roots. */ -type InferValue = - S extends StringValueSchemaSpec ? InferScalar : - S extends NumberValueSchemaSpec | IntegerValueSchemaSpec ? InferScalar : - S extends BooleanValueSchemaSpec ? InferScalar : - S extends NullValueSchemaSpec ? null : - S extends ArrayValueSchemaSpec - ? S extends { items: infer I extends ValueSchemaSpec } ? InferValue[] : JsonValue[] - : S extends ObjectValueSchemaSpec ? InferObject : - S extends JsonValueSchemaSpec ? JsonValue : - S extends OneOfValueSchemaSpec ? InferValue : - never +type InferValue = + D['length'] extends 12 ? JsonValue : + S extends StringValueSchemaSpec ? InferScalar : + S extends NumberValueSchemaSpec | IntegerValueSchemaSpec ? InferScalar : + S extends BooleanValueSchemaSpec ? InferScalar : + S extends NullValueSchemaSpec ? null : + S extends ArrayValueSchemaSpec + ? S extends { items: infer I extends ValueSchemaSpec } ? InferValue>[] : JsonValue[] + : S extends ObjectValueSchemaSpec ? InferObject> : + S extends JsonValueSchemaSpec ? JsonValue : + S extends OneOfValueSchemaSpec ? InferValue> : + never ``` ```ts type-equiv /** Infer the TypeScript argument object for an implicit parameter schema. */ -type InferArgs = InferProperties +type InferArgs = InferProperties ``` -`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. +`defineTool({ name, description, parameters, output, execute, … })` ties parameter inference to `parameterSchemaSpecToJsonSchema()` and `validateArgs()`, and ties `execute`/`render`/`presentationMeta` to `InferValue`. Inference widens to `JsonValue` after twelve nested nodes so large schemas remain compilable; runtime validation keeps walking the complete schema. `valueSchemaSpecToJsonSchema()` compiles output declarations through the same enforced raw subset. A parameter mismatch throws `ToolArgsError` (`INVALID_ARGS`); an invalid body or post-policy value throws `ToolOutputError` (`INVALID_TOOL_OUTPUT`). Both use 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, requires `output`, validates its raw schema, and checks 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. ## `ToolRestriction` — one scope's live global filter @@ -230,34 +246,48 @@ type ToolGuard = (execution: Readonly) => string | undefined ``` ```ts type-equiv -/** The outcome of one tool call. */ -interface ToolExecutionResult { - content: ContentBlock[] - isError: boolean - /** - * Set when the call failed with a {@link HarnessError}: machine-routable - * `{ name, code }` for retry/sandbox plugins and replay. The model-facing - * text in `content` is always present; this is extra structure for code. - */ - error?: ToolErrorInfo - /** - * Model-facing context for the next request, separate from this tool result. The loop - * accepts it into the active-batch FIFO, then appends after recorded results even if interrupted. - */ - additionalContexts?: HookContext[] - /** - * The tool-private presentation payload from a successful `execute` (the object - * return form). Threaded onto the `tool/result` session event and back into - * {@link ToolResult} for `presentResult`. Opaque (`unknown`); absent when the - * tool attached none or the call failed. - */ - meta?: unknown +/** Canonical failure detail; internal routing information remains optional. */ +interface ToolFailure { + /** Human-readable failure message without the Native `Error: ` envelope. */ + message: string + /** Internal error class/code used by policy and durable diagnostics. */ + info?: ToolErrorInfo } ``` -The result carries only the outcome. Call identity remains on the immutable `ToolExecution` that accompanies it through every hook and on the durable `tool/call` / `tool/result` session events, so wrappers cannot create a second, disagreeing identity. +```ts type-equiv +/** Successful canonical tool execution, including its Native/model projection. */ +interface ToolExecutionSuccess { + readonly isError: false + /** Execution-local canonical value; deliberately omitted from durable events. */ + readonly value: JsonValue + readonly content: ContentBlock[] + readonly error?: never + readonly meta?: JsonValue + readonly additionalContexts?: HookContext[] +} +``` -The registry materializes and freezes the final accepted result immediately before `tools/result`. Its content, structured error, additional context, and presentation metadata must round-trip losslessly through JSON; an invalid outcome becomes a JSON-safe `isError` result, so the observed live outcome is safe for the later durable `tool/result` append. +```ts type-equiv +/** Failed canonical tool execution; failures never carry a successful value. */ +interface ToolExecutionFailure { + readonly isError: true + readonly error: ToolFailure + readonly value?: never + readonly content: ContentBlock[] + readonly meta?: JsonValue + readonly additionalContexts?: HookContext[] +} +``` + +```ts type-equiv +/** The discriminated, execution-local outcome of one tool call. */ +type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure +``` + +The result carries only the outcome. Call identity remains on the immutable `ToolExecution` that accompanies it through every hook and on the durable `tool/call` / `tool/result` session events, so wrappers cannot create a second, disagreeing identity. The canonical `value` is execution-local: the loop persists only `content`, `error`, and `meta`, while `tool/code-dispatch` stores a bounded summary. Replay reproduces presentation but cannot reconstruct intermediate values. + +On success the registry snapshots and validates the body value, freezes it, and invokes the pure renderer plus the optional direct-surface metadata projector. It separately materializes the durable presentation fields immediately before `tools/result`; an invalid value, renderer/projector failure, or non-JSON presentation becomes a JSON-safe `isError`. The final live observer therefore sees the exact execution-local value beside fields safe for the later durable append. Each interception waterfall returns a typed **Decision** (the idiom shared with the `agent/*` seams). `tools/pre-execute` listeners receive `(exec, next)` and return a `PreToolDecision`; `tools/execute` wrappers return a `ToolExecutionResult`; `tools/post-execute` listeners receive `(exec, result, next)` and return a `PostToolDecision`: @@ -276,17 +306,18 @@ type PreToolDecision = ```ts type-equiv /** - * Post-dispatch decision: accept or replace content, attach context for the next - * request, or block by turning corrective feedback into an error result. + * Post-dispatch decision: accept, replace one projection, attach context for the + * next request, or block by turning corrective feedback into an error result. */ type PostToolDecision = - | { kind: 'accept'; content?: ContentBlock[]; additionalContexts?: HookContext[] } + | { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: HookContext[] } + | { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: HookContext[] } | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: HookContext[] } ``` Call `next()` for the default or return a decision to short-circuit. Pre-policy may deny or ask; only `allowed-once` proceeds, while a non-grant, missing approval channel or service, or agent-less request becomes a denial. Guards may still impose a final denial. Arguments cannot be rewritten because history, audit, UI, and execution must agree. -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 either content or value, never both. Content replacement preserves the canonical value and existing metadata; value replacement is revalidated and recomputes content/metadata; a block removes the value and becomes an `isError` containing corrective feedback. Content replacement is presentation policy, not confidentiality policy: a listener that must hide the programmatic value blocks or replaces it. `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 enforced raw JSON Schema subset diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 6185522793..f84eee5ac7 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -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) | | `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`) | - | -| `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: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: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: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: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) | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:135`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:108`](../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:117`](../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), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:99`](../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:125`](../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-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`) | - | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 80b0b7196d..9ca545854c 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -79,7 +79,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:267`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:336`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:313`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:345`](../packages/core/session/src/types.ts) ## Events @@ -356,7 +356,7 @@ Source: [`packages/core/session/src/types.ts:213`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:272`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -387,7 +387,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:34`](../packages/s Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:256`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:265`](../packages/core/session/src/types.ts) ### `step/*` @@ -420,7 +420,7 @@ Source: [`packages/core/session/src/types.ts:204`](../packages/core/session/src/ Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:258`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:267`](../packages/core/session/src/types.ts) ### `tool/*` @@ -468,20 +468,29 @@ Source: [`packages/core/tools/src/code-mode.ts:34`](../packages/core/tools/src/c ```ts persistence-catalog /** - * A completed tool call's model-facing result, plus an optional tool-private - * `meta` presentation payload. `meta` is opaque to the core (`unknown` — the - * producing tool owns its shape and reads it back in `presentResult`) but MUST - * be JSON-serializable: `Session.append` runtime-validates all event data with - * `isJsonValue`, so a non-serializable `meta` is rejected at the source, and the - * durable log reproduces the identical card on replay. Absent unless the tool - * attaches one (e.g. `dsh-tool-fs` carries its result-time contextual diff here). + * A completed tool call's model-facing result, canonical failure detail, and + * optional tool-private `meta` presentation payload. `meta` is opaque to the + * core (the producing tool owns its shape and reads it back in `presentResult`) + * but MUST be JSON-serializable: `Session.append` runtime-validates all event + * data with `isJsonValue`, so a non-serializable `meta` is rejected at the + * source, and the durable log reproduces the identical card on replay. Absent + * unless the tool attaches one (e.g. `dsh-tool-fs` carries its result-time + * contextual diff here). */ -'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown } +'tool/result': { + turn: number + step: number + callId: CallId + content: ContentBlock[] + isError: boolean + error?: { message: string; info?: { name: string; code: string } } + meta?: JsonValue +} ``` Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:254`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:255`](../packages/core/session/src/types.ts) ### `turn/*` diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index eaf24e38a7..fa75d4dd17 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -203,7 +203,7 @@ Source: [`packages/cordis/tool-cordis/src/index.ts`](../packages/cordis/tool-cor ### `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'|'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:]`, 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, 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 { diff --git a/docs/user/develop/basic/index.i18n.yaml b/docs/user/develop/basic/index.i18n.yaml index 711715a5de..a6ab84c3e0 100644 --- a/docs/user/develop/basic/index.i18n.yaml +++ b/docs/user/develop/basic/index.i18n.yaml @@ -2,5 +2,5 @@ # 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 -index.md: d7d657ff7b8cb9001dd5e9c3af658a7a3c45b5b7 -index.zh.md: 7a134f7aaed470b87ee8ca8978dd39593de2651b +index.md: 5a9f8dfb8f2d87dfbd2ba30b4d09d002ae9b635c +index.zh.md: 08aca87cbc02d1b0dfbe6fe2d92b3f6e87075097 diff --git a/docs/user/develop/basic/index.md b/docs/user/develop/basic/index.md index d7d657ff7b..5a9f8dfb8f 100644 --- a/docs/user/develop/basic/index.md +++ b/docs/user/develop/basic/index.md @@ -138,8 +138,12 @@ export function apply(ctx: Context) { parameters: { name: { type: 'string', required: true }, }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, async execute(args) { - return [{ type: 'text', text: `Hello, ${args.name}!` }] + return `Hello, ${args.name}!` }, })) } diff --git a/docs/user/develop/basic/index.zh.md b/docs/user/develop/basic/index.zh.md index 7a134f7aae..08aca87cbc 100644 --- a/docs/user/develop/basic/index.zh.md +++ b/docs/user/develop/basic/index.zh.md @@ -138,8 +138,12 @@ export function apply(ctx: Context) { parameters: { name: { type: 'string', required: true }, }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, async execute(args) { - return [{ type: 'text', text: `Hello, ${args.name}!` }] + return `Hello, ${args.name}!` }, })) } diff --git a/docs/user/develop/basic/tool.i18n.yaml b/docs/user/develop/basic/tool.i18n.yaml index b741ccf65f..73970f99d8 100644 --- a/docs/user/develop/basic/tool.i18n.yaml +++ b/docs/user/develop/basic/tool.i18n.yaml @@ -2,5 +2,5 @@ # 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 -tool.md: 17adbfc5f7eb804856dfe39b4d2b4a65374b6414 -tool.zh.md: 8857e16ca836dfa5b158a68581bd3c715dcb1ac5 +tool.md: 7b211cfef54306f7c316dc08da1df759dcbf1b06 +tool.zh.md: 214b35b28de0c647737bc8297b13b4997947b52e diff --git a/docs/user/develop/basic/tool.md b/docs/user/develop/basic/tool.md index 17adbfc5f7..7b211cfef5 100644 --- a/docs/user/develop/basic/tool.md +++ b/docs/user/develop/basic/tool.md @@ -20,9 +20,13 @@ export function apply(ctx: Context) { parameters: { name: { type: 'string', required: true, description: 'The name to greet' }, }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, async execute(args) { // args is inferred as { name: string }. - return [{ type: 'text', text: `Hello, ${args.name}!` }] + return `Hello, ${args.name}!` }, })) } @@ -107,33 +111,45 @@ export const tool = defineTool({ name: 'example', description: 'Return an example result.', parameters: {}, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, async execute(args, exec) { // args: inferred from parameters // exec: ToolExecution context - // Return a ContentBlock array. + // Return the value declared by output.schema. void args void exec - return [{ type: 'text', text: 'result here' }] + return 'result here' }, }) ``` ### Return value -`execute` returns a `ContentBlock[]` that becomes the tool result visible to the model: +`execute` returns the lossless JSON value declared by `output.schema`. `output.render(args, value)` separately turns that validated value into the Native/model-facing content: ```ts ignore-check -// Text result -return [{ type: 'text', text: 'file content here...' }] - -// Multiple blocks -return [ - { type: 'text', text: 'Found 3 matches:' }, - { type: 'text', text: matchResults.join('\n') }, -] +output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + path: { type: 'string', required: true }, + content: { type: 'string', required: true }, + }, + }, + render: (_args, value) => [{ type: 'text', text: value.content }], +}, +async execute(args) { + return { path: args.path, content: await readFile(args.path, 'utf8') } +} ``` +The canonical value is available to execution-time programmatic callers and is not persisted in `tool/result`; the rendered content and optional `presentationMeta` are the replayable projections. A body value that does not satisfy the schema, or is not lossless JSON, becomes an `INVALID_TOOL_OUTPUT` failure. + ### Argument validation Before calling `execute`, `defineTool` validates model-generated arguments. Invalid input raises `ToolArgsError`; the framework turns it into an `isError` result so the model can correct its call. @@ -148,6 +164,10 @@ A tool can define UI presentation methods for terminal and ACP clients: defineTool({ name: 'bash', // ... + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, presentCall(args) { return { card: 'terminal', @@ -196,13 +216,24 @@ export function apply(ctx: Context) { path: { type: 'string', required: true, description: 'Directory path' }, extension: { type: 'string', description: 'Filter by extension (e.g. ".ts")' }, }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + count: { type: 'integer', required: true }, + files: { type: 'array', required: true, items: { type: 'string' } }, + }, + }, + render: (_args, value) => [{ type: 'text', text: `Found ${value.count} files.` }], + }, async execute(args) { const entries = await readdir(args.path, { withFileTypes: true }) let files = entries.filter(e => e.isFile()) if (args.extension) { files = files.filter(f => f.name.endsWith(args.extension!)) } - return [{ type: 'text', text: `Found ${files.length} files.` }] + return { count: files.length, files: files.map(file => file.name) } }, })) } diff --git a/docs/user/develop/basic/tool.zh.md b/docs/user/develop/basic/tool.zh.md index 8857e16ca8..214b35b28d 100644 --- a/docs/user/develop/basic/tool.zh.md +++ b/docs/user/develop/basic/tool.zh.md @@ -20,9 +20,13 @@ export function apply(ctx: Context) { parameters: { name: { type: 'string', required: true, description: 'The name to greet' }, }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, async execute(args) { // args is inferred as { name: string }. - return [{ type: 'text', text: `Hello, ${args.name}!` }] + return `Hello, ${args.name}!` }, })) } @@ -107,33 +111,45 @@ export const tool = defineTool({ name: 'example', description: 'Return an example result.', parameters: {}, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, async execute(args, exec) { // args: inferred from parameters // exec: ToolExecution context - // Return a ContentBlock array. + // Return the value declared by output.schema. void args void exec - return [{ type: 'text', text: 'result here' }] + return 'result here' }, }) ``` ### 返回值 -`execute` 必须返回一个 `ContentBlock[]`,告诉模型 tool 的执行结果: +`execute` 返回由 `output.schema` 声明的无损 JSON 值。`output.render(args, value)` 会将经过校验的值另外转换为 Native/模型可见的内容: ```ts ignore-check -// Text result -return [{ type: 'text', text: 'file content here...' }] - -// Multiple blocks -return [ - { type: 'text', text: 'Found 3 matches:' }, - { type: 'text', text: matchResults.join('\n') }, -] +output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + path: { type: 'string', required: true }, + content: { type: 'string', required: true }, + }, + }, + render: (_args, value) => [{ type: 'text', text: value.content }], +}, +async execute(args) { + return { path: args.path, content: await readFile(args.path, 'utf8') } +} ``` +执行期间的程序化调用方可以使用规范值,但 `tool/result` 不会持久化该值;渲染后的内容和可选的 `presentationMeta` 才是可回放的投影。工具主体返回的值若不满足 schema 或不是无损 JSON,就会变为 `INVALID_TOOL_OUTPUT` 失败。 + ### 参数校验 `defineTool` 在调用 `execute` 之前会自动校验模型生成的参数。如果参数不合法,会抛出 `ToolArgsError`,框架将其转换为 `isError` 结果返回给模型,让模型自行修正。 @@ -148,6 +164,10 @@ Tool 可以定义 UI 渲染方法,用于在终端或 ACP 客户端中展示 to defineTool({ name: 'bash', // ... + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, presentCall(args) { return { card: 'terminal', @@ -196,13 +216,24 @@ export function apply(ctx: Context) { path: { type: 'string', required: true, description: 'Directory path' }, extension: { type: 'string', description: 'Filter by extension (e.g. ".ts")' }, }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + count: { type: 'integer', required: true }, + files: { type: 'array', required: true, items: { type: 'string' } }, + }, + }, + render: (_args, value) => [{ type: 'text', text: `Found ${value.count} files.` }], + }, async execute(args) { const entries = await readdir(args.path, { withFileTypes: true }) let files = entries.filter(e => e.isFile()) if (args.extension) { files = files.filter(f => f.name.endsWith(args.extension!)) } - return [{ type: 'text', text: `Found ${files.length} files.` }] + return { count: files.length, files: files.map(file => file.name) } }, })) } diff --git a/docs/user/develop/practice/index.i18n.yaml b/docs/user/develop/practice/index.i18n.yaml index d2478abf75..799dffc1c6 100644 --- a/docs/user/develop/practice/index.i18n.yaml +++ b/docs/user/develop/practice/index.i18n.yaml @@ -2,5 +2,5 @@ # 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 -index.md: 0261b49b071167f7c2a33f78bbc1959cc6f1879f -index.zh.md: 5819344430fcbde31bf825e9815120983e44e3f6 +index.md: e197d499d7f5bd9911ea60bebf584251cd4ed915 +index.zh.md: 8b8d08f9d0c6d0ca8d95fbaa3281c98b7a600fe4 diff --git a/docs/user/develop/practice/index.md b/docs/user/develop/practice/index.md index 0261b49b07..e197d499d7 100644 --- a/docs/user/develop/practice/index.md +++ b/docs/user/develop/practice/index.md @@ -132,9 +132,13 @@ export function apply(ctx: Context) { parameters: { input: { type: 'string', required: true }, }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, async execute(args) { const result = await ctx.myCap.execute({ input: args.input }) - return [{ type: 'text', text: result.output }] + return result.output }, })) } diff --git a/docs/user/develop/practice/index.zh.md b/docs/user/develop/practice/index.zh.md index 5819344430..8b8d08f9d0 100644 --- a/docs/user/develop/practice/index.zh.md +++ b/docs/user/develop/practice/index.zh.md @@ -132,9 +132,13 @@ export function apply(ctx: Context) { parameters: { input: { type: 'string', required: true }, }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, async execute(args) { const result = await ctx.myCap.execute({ input: args.input }) - return [{ type: 'text', text: result.output }] + return result.output }, })) } diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index 3e111b8ba2..e463aff141 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -63,7 +63,7 @@ declare const tools: { /** Exact service key or event name whose original JSDoc to include; valid only with what:"api" or what:"events". */ name?: string; } & Record): Promise; - /** 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:]`, 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, 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: { /** Body of an async JS function; must `return` the plugin to mount. */ code: string; diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index 95cdd43b83..487af4392e 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -72,7 +72,7 @@ }, { "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'|'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:]`, 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, 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": { "type": "object", "properties": { diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl index cdb76be163..db08475efd 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl @@ -13,8 +13,8 @@ {"type":"assistant/chunk","seq":11,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":12,"time":1784437195078,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"},{"type":"tool-call","id":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":10}},"sourceEventSeqs":[4,5,6,7,8,9,10,11],"surfaceOp":"append"} {"type":"tool/call","seq":13,"time":1784437195078,"data":{"turn":1,"step":1,"callId":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}} -{"type":"tool/result","seq":14,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_wait","content":[{"type":"text","text":"Error: command aborted"}],"isError":true},"sourceEventSeqs":[13],"surfaceOp":"append"} +{"type":"tool/result","seq":14,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_wait","content":[{"type":"text","text":"Error: command aborted"}],"isError":true,"error":{"message":"command aborted"}},"sourceEventSeqs":[13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}} -{"type":"tool/result","seq":16,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_skipped","content":[{"type":"text","text":"Error: tool call skipped because the step was aborted before execution"}],"isError":true,"error":{"name":"AbortError","code":"ABORTED"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_skipped","content":[{"type":"text","text":"Error: tool call skipped because the step was aborted before execution"}],"isError":true,"error":{"message":"tool call skipped because the step was aborted before execution","info":{"name":"AbortError","code":"ABORTED"}}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1784437195090,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":18,"time":1784437195090,"data":{"turn":1,"reason":{"kind":"aborted","reason":"session/cancel"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 207f8d8cc3..f04f7c9cf1 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -10,7 +10,7 @@ {"type":"assistant/chunk","seq":8,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":9,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} {"type":"tool/call","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":11,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n }\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"tool/result","seq":11,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n }\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\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 }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} {"type":"step/end","seq":12,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":13,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":14,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl index 5528c956d8..45ae5c9f50 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl @@ -2,7 +2,7 @@ {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"Inspect cordis runtime: api: tools","kind":"read","status":"in_progress"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n }\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n }\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\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 }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-event","title":"Inspect cordis runtime: events: tools/pre-execute","kind":"read","status":"in_progress"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-event","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index ff6d1187b3..4190704a7f 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -157,7 +157,7 @@ {"type":"tool/call","seq":155,"time":1783962246274,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} {"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"46c8dba4-52c9-4a6a-b6a6-5f34c95c28df","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} {"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"46c8dba4-52c9-4a6a-b6a6-5f34c95c28df","outcome":"rejected"}} -{"type":"tool/result","seq":158,"time":1783962246275,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[155],"surfaceOp":"append"} +{"type":"tool/result","seq":158,"time":1783962246275,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true,"error":{"message":"the user rejected escalating this command to \"danger-full-access\""}},"sourceEventSeqs":[155],"surfaceOp":"append"} {"type":"step/end","seq":159,"time":1783962246276,"data":{"turn":1,"step":1}} {"type":"step/start","seq":160,"time":1783962246276,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":161,"time":1783860683140,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl index 54601f5354..013b002edc 100644 --- a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl @@ -91,7 +91,7 @@ {"type":"tool/call","seq":89,"time":1784045703780,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}} {"type":"approval/asked","seq":90,"time":1784045703782,"data":{"id":"c37500b3-c252-4a9b-ad0d-9c4349419b30","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} {"type":"approval/decided","seq":91,"time":1784045703786,"data":{"id":"c37500b3-c252-4a9b-ad0d-9c4349419b30","outcome":"allowed-once"}} -{"type":"tool/result","seq":92,"time":1784045703798,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md\nfile\n\nCreated file\n"}],"isError":false},"sourceEventSeqs":[89],"surfaceOp":"append"} +{"type":"tool/result","seq":92,"time":1784045703798,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md\nfile\n\nCreated file\n"}],"isError":false,"meta":{"diffs":[]}},"sourceEventSeqs":[89],"surfaceOp":"append"} {"type":"step/end","seq":93,"time":1784045703798,"data":{"turn":1,"step":1}} {"type":"step/start","seq":94,"time":1784045703799,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":95,"time":1784045704512,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl index 66efd5f934..5b1ccd60fc 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl @@ -77,7 +77,7 @@ {"type":"assistant/chunk","seq":75,"time":1783611703969,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":76,"time":1783611703972,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the edit tool to replace \"blue\" with \"green\" in settings.txt without reading the file first, and then reply with just \"DONE\"."},{"type":"tool-call","id":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3132,"outputTokens":115,"cacheReadTokens":0,"reasoningTokens":36}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75],"surfaceOp":"append"} {"type":"tool/call","seq":77,"time":1783611703972,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} -{"type":"tool/result","seq":78,"time":1783611703978,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt\" first"}],"isError":true,"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[77],"surfaceOp":"append"} +{"type":"tool/result","seq":78,"time":1783611703978,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt\" first"}],"isError":true,"error":{"message":"edit requires reading \"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt\" first","info":{"name":"FsError","code":"FS_NOT_OBSERVED"}}},"sourceEventSeqs":[77],"surfaceOp":"append"} {"type":"step/end","seq":79,"time":1783611703978,"data":{"turn":1,"step":1}} {"type":"step/start","seq":80,"time":1783611703978,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":81,"time":1783611704825,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl index 04fd86da0d..acbe253e99 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl @@ -62,7 +62,7 @@ {"type":"assistant/chunk","seq":60,"time":1783352079886,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":61,"time":1783352079888,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to create a file named notes.txt with the content \"hello world\" using the write tool, then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2891,"outputTokens":92,"cacheReadTokens":0,"reasoningTokens":30}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60],"surfaceOp":"append"} {"type":"tool/call","seq":62,"time":1783352079888,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}} -{"type":"tool/result","seq":63,"time":1783352079897,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","content":[{"type":"text","text":"/tmp/acp-snap-cwd-sNvn5N/notes.txt\nfile\n\nCreated file\n"}],"isError":false},"sourceEventSeqs":[62],"surfaceOp":"append"} +{"type":"tool/result","seq":63,"time":1783352079897,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","content":[{"type":"text","text":"/tmp/acp-snap-cwd-sNvn5N/notes.txt\nfile\n\nCreated file\n"}],"isError":false,"meta":{"diffs":[]}},"sourceEventSeqs":[62],"surfaceOp":"append"} {"type":"step/end","seq":64,"time":1783352079898,"data":{"turn":1,"step":1}} {"type":"step/start","seq":65,"time":1783352079899,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":66,"time":1783352080825,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl index c362de7a26..8cab55c391 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl @@ -75,7 +75,7 @@ {"type":"tool/call","seq":73,"time":1783962505993,"data":{"turn":1,"step":1,"callId":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}} {"type":"hook/invoked","seq":74,"time":1783962506001,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":75,"time":1783962506011,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:1","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: retry once","durationMs":9.922291999999743}} -{"type":"tool/result","seq":76,"time":1783962506011,"data":{"turn":1,"step":1,"callId":"call_00_VAByyMjsct4c7P6k1ysX9256","content":[{"type":"text","text":"tool output rejected by policy: retry once"}],"isError":true},"sourceEventSeqs":[73],"surfaceOp":"append"} +{"type":"tool/result","seq":76,"time":1783962506011,"data":{"turn":1,"step":1,"callId":"call_00_VAByyMjsct4c7P6k1ysX9256","content":[{"type":"text","text":"tool output rejected by policy: retry once"}],"isError":true,"error":{"message":"tool output rejected by policy: retry once"}},"sourceEventSeqs":[73],"surfaceOp":"append"} {"type":"step/end","seq":77,"time":1783962506012,"data":{"turn":1,"step":1}} {"type":"step/start","seq":78,"time":1783962506012,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":79,"time":1783962507038,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl index 247e13a075..1309b5c30d 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -57,7 +57,7 @@ {"type":"hook/result","seq":55,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}} {"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"68f1e09d-f3e5-4e39-8a51-da082ba3ba99","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} {"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"68f1e09d-f3e5-4e39-8a51-da082ba3ba99","outcome":"rejected"}} -{"type":"tool/result","seq":58,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[53],"surfaceOp":"append"} +{"type":"tool/result","seq":58,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true,"error":{"message":"the user rejected tool \"bash\""}},"sourceEventSeqs":[53],"surfaceOp":"append"} {"type":"step/end","seq":59,"time":1783962235814,"data":{"turn":1,"step":1}} {"type":"step/start","seq":60,"time":1783962235814,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":61,"time":1783352173584,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl index 4193c7fe80..869fca1eca 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl @@ -55,7 +55,7 @@ {"type":"tool/call","seq":53,"time":1783352166514,"data":{"turn":1,"step":1,"callId":"call_00_JliP571Bh0QQ8QExbSPk0080","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}} {"type":"hook/invoked","seq":54,"time":1783352166515,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":55,"time":1783352166528,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"block","exitCode":2,"stderrSummary":"bash is disabled by policy in this session","durationMs":12.367074000000684}} -{"type":"tool/result","seq":56,"time":1783352166528,"data":{"turn":1,"step":1,"callId":"call_00_JliP571Bh0QQ8QExbSPk0080","content":[{"type":"text","text":"Error: bash is disabled by policy in this session"}],"isError":true},"sourceEventSeqs":[53],"surfaceOp":"append"} +{"type":"tool/result","seq":56,"time":1783352166528,"data":{"turn":1,"step":1,"callId":"call_00_JliP571Bh0QQ8QExbSPk0080","content":[{"type":"text","text":"Error: bash is disabled by policy in this session"}],"isError":true,"error":{"message":"bash is disabled by policy in this session"}},"sourceEventSeqs":[53],"surfaceOp":"append"} {"type":"step/end","seq":57,"time":1783352166529,"data":{"turn":1,"step":1}} {"type":"step/start","seq":58,"time":1783352166529,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":59,"time":1783352167307,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl index dc68891c14..4a05a2daa7 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl @@ -66,7 +66,7 @@ {"type":"tool/call","seq":64,"time":1783986963664,"data":{"turn":1,"step":1,"callId":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}} {"type":"hook/invoked","seq":65,"time":1783986963673,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":66,"time":1783986963677,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:1","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead","durationMs":4.42941699999983}} -{"type":"tool/result","seq":67,"time":1783986963678,"data":{"turn":1,"step":1,"callId":"call_00_1rmSWHhVchVg7PDTmegT0421","content":[{"type":"text","text":"tool output rejected by codex policy: summarize instead"}],"isError":true},"sourceEventSeqs":[64],"surfaceOp":"append"} +{"type":"tool/result","seq":67,"time":1783986963678,"data":{"turn":1,"step":1,"callId":"call_00_1rmSWHhVchVg7PDTmegT0421","content":[{"type":"text","text":"tool output rejected by codex policy: summarize instead"}],"isError":true,"error":{"message":"tool output rejected by codex policy: summarize instead"}},"sourceEventSeqs":[64],"surfaceOp":"append"} {"type":"step/end","seq":68,"time":1783986963678,"data":{"turn":1,"step":1}} {"type":"step/start","seq":69,"time":1783986963679,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":70,"time":1783986964555,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl index c2675ae3af..eb928d7dff 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl @@ -55,7 +55,7 @@ {"type":"tool/call","seq":53,"time":1783352215804,"data":{"turn":1,"step":1,"callId":"call_00_tv0SMeLXaTuyuVrOxnV97085","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}} {"type":"hook/invoked","seq":54,"time":1783352215805,"data":{"turn":1,"point":"PreToolUse","dialect":"codex","handlerId":"codex:PreToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":55,"time":1783352215832,"data":{"turn":1,"point":"PreToolUse","handlerId":"codex:PreToolUse:1","decision":"block","exitCode":2,"stderrSummary":"bash is disabled by codex policy in this session","durationMs":26.08518500000082}} -{"type":"tool/result","seq":56,"time":1783352215832,"data":{"turn":1,"step":1,"callId":"call_00_tv0SMeLXaTuyuVrOxnV97085","content":[{"type":"text","text":"Error: bash is disabled by codex policy in this session"}],"isError":true},"sourceEventSeqs":[53],"surfaceOp":"append"} +{"type":"tool/result","seq":56,"time":1783352215832,"data":{"turn":1,"step":1,"callId":"call_00_tv0SMeLXaTuyuVrOxnV97085","content":[{"type":"text","text":"Error: bash is disabled by codex policy in this session"}],"isError":true,"error":{"message":"bash is disabled by codex policy in this session"}},"sourceEventSeqs":[53],"surfaceOp":"append"} {"type":"step/end","seq":57,"time":1783352215833,"data":{"turn":1,"step":1}} {"type":"step/start","seq":58,"time":1783352215834,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":59,"time":1783352216779,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl index 7b36136970..ed8f566219 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl @@ -10,7 +10,7 @@ {"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":9,"time":1784540790335,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_depth_three_rejected","name":"subagent","arguments":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} {"type":"tool/call","seq":10,"time":1784540790335,"data":{"turn":1,"step":1,"callId":"call_depth_three_rejected","name":"subagent","arguments":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}} -{"type":"tool/result","seq":11,"time":1784540790337,"data":{"turn":1,"step":1,"callId":"call_depth_three_rejected","content":[{"type":"text","text":"Error: subagent depth 3 exceeds maxDepth 2"}],"isError":true},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"tool/result","seq":11,"time":1784540790337,"data":{"turn":1,"step":1,"callId":"call_depth_three_rejected","content":[{"type":"text","text":"Error: subagent depth 3 exceeds maxDepth 2"}],"isError":true,"error":{"message":"subagent depth 3 exceeds maxDepth 2"}},"sourceEventSeqs":[10],"surfaceOp":"append"} {"type":"step/end","seq":12,"time":1784540790338,"data":{"turn":1,"step":1}} {"type":"step/start","seq":13,"time":1784540790338,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":14,"time":1784540790339,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index 258967fad9..a7eb37f9e3 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -49,6 +49,8 @@ The overlay is computed from the current `ToolExecution` and passed through the Result text contains stdout, an optional `[stderr]` section, then applicable sandbox-denial, timeout, signal, exit-code, and truncation markers. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Truncation links a safe complete spill file or reports it unavailable. Only infrastructure failures such as spawn errors and aborts produce `isError`. +The canonical success is `{ kind: 'foreground', ...BashRunResult }` for a completed foreground process or `{ kind: 'background', taskId }` for a published task. The Native renderer preserves the text above, including exactly `started background task `; programmatic consumers use the typed fields without parsing those strings. Executor stream caps remain acquisition limits on `BashRunResult` and carry their spill paths. + When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` before spawning, registers the calling agent as owner, and adapts the returned `BashProcess` handle into generic cancel/done/incremental-output hooks. The task runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps bash exit/sandbox facts into task output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time. ## UI presentation diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 6098056fb9..6ee2fc2b99 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -22,7 +22,7 @@ import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash' -import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash' +import type { BashRunResult, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash' import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-home' import { processOutcome } from './background.ts' import { parseExitStatus, renderProcessRead, renderResult } from './render.ts' @@ -311,6 +311,38 @@ function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent return modelWorkdir } +/** Detach the executor DTO from readonly seam interfaces into plain JSON data. */ +function canonicalBashResult(result: BashRunResult) { + const output = (stream: BashRunResult['stdout']) => ({ + text: stream.text, + truncated: stream.truncated, + ...stream.spillPath !== undefined ? { spillPath: stream.spillPath } : {}, + }) + return { + exitCode: result.exitCode, + signal: result.signal, + timedOut: result.timedOut, + aborted: result.aborted, + timeoutMs: result.timeoutMs, + stdout: output(result.stdout), + stderr: output(result.stderr), + ...result.sandbox !== undefined ? { + sandbox: { + mode: result.sandbox.mode, + denied: result.sandbox.denied, + ...result.sandbox.enforcement !== undefined ? { enforcement: result.sandbox.enforcement } : {}, + ...result.sandbox.runnerFailed !== undefined ? { runnerFailed: result.sandbox.runnerFailed } : {}, + }, + } : {}, + } +} + +/** Canonical background-handle properties shared by the bash output union. */ +const BACKGROUND_OUTPUT_PROPERTIES = { + kind: { type: 'string', required: true, const: 'background' }, + taskId: { type: 'string', required: true }, +} as const + export function apply(ctx: Context, config: Config = {}): void { const bashEnv = new BashEnvRegistry(ctx, config) bashEnv.register({ @@ -398,6 +430,65 @@ export function apply(ctx: Context, config: Config = {}): void { }, } : {}, }, + output: { + schema: { + oneOf: [ + { + type: 'object', + additionalProperties: false, + properties: BACKGROUND_OUTPUT_PROPERTIES, + }, + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'foreground' }, + exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] }, + signal: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] }, + timedOut: { type: 'boolean', required: true }, + aborted: { type: 'boolean', required: true }, + timeoutMs: { type: 'number', required: true }, + stdout: { + type: 'object', + additionalProperties: false, + required: true, + properties: { + text: { type: 'string', required: true }, + truncated: { type: 'boolean', required: true }, + spillPath: { type: 'string' }, + }, + }, + stderr: { + type: 'object', + additionalProperties: false, + required: true, + properties: { + text: { type: 'string', required: true }, + truncated: { type: 'boolean', required: true }, + spillPath: { type: 'string' }, + }, + }, + sandbox: { + type: 'object', + additionalProperties: false, + properties: { + mode: { type: 'string', required: true }, + denied: { type: 'boolean', required: true }, + enforcement: { type: 'string' }, + runnerFailed: { type: 'boolean' }, + }, + }, + }, + }, + ], + }, + render: (_args, value) => [{ + type: 'text', + text: value.kind === 'background' + ? `started background task ${value.taskId}` + : renderResult(value as { kind: 'foreground' } & BashRunResult, escalationModes), + }], + }, async execute(args: BashToolArgs, exec) { validateBashArgs(args) // Description is display metadata; workdir defaults to the caller's session. @@ -438,14 +529,14 @@ export function apply(ctx: Context, config: Config = {}): void { } }, }) - return [{ type: 'text', text: `started background task ${id}` }] + return { kind: 'background' as const, taskId: id } } const result = await ctx.bash.run(ctx.bash.resolve({ ...request, ...exec.signal ? { signal: exec.signal } : {}, })) if (result.aborted) throw new Error('command aborted') - return [{ type: 'text', text: renderResult(result, escalationModes) }] + return { kind: 'foreground' as const, ...canonicalBashResult(result) } }, presentCall: presentBashCall, presentResult: presentBashResult, diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index b2a78ff9fe..0158f5f908 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -119,7 +119,13 @@ class RecordingSandboxExecutor extends BashExecutor { timeoutMs: spec.timeoutMs, stdout: { text: 'ok', truncated: false }, stderr: { text: '', truncated: false }, - sandbox: { mode: spec.sandboxMode ?? 'read-only', denied: false }, + sandbox: { + mode: spec.sandboxMode ?? 'read-only', + denied: false, + ...spec.command === 'without optional sandbox facts' + ? {} + : { enforcement: 'full' as const, runnerFailed: false }, + }, }) } @@ -204,6 +210,16 @@ describe('bash tool', () => { const ctx = await setup() const result = await call(ctx, 'bash', { command: 'echo hello', description: 'test command' }) expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected bash success') + expect(result.value).toMatchObject({ + kind: 'foreground', + exitCode: 0, + signal: null, + timedOut: false, + aborted: false, + stdout: { text: 'hello\n', truncated: false }, + stderr: { text: '', truncated: false }, + }) expect(text(result)).toBe('hello\n') }) @@ -399,6 +415,8 @@ describe('background execution through the task runtime', () => { const ctx = await setupWithTasks() const started = await call(ctx, 'bash', { command: 'echo bg-ok', description: 'test command', run_in_background: true }) expect(started.isError).toBe(false) + if (started.isError) throw new Error('expected background bash success') + expect(started.value).toEqual({ kind: 'background', taskId: 'bash-1' }) expect(text(started)).toBe('started background task bash-1') const read = await callUntilText(ctx, 'task_output', { task_id: 'bash-1' }, 'bg-ok') @@ -606,6 +624,22 @@ describe('sandbox escalation through the generic task producer', () => { expect(bash.modes).toEqual(['workspace-write', 'danger-full-access']) }) + it('omits sandbox facts the executor did not acquire from the canonical result', async () => { + const { ctx } = await setupSandboxed() + const result = await call(ctx, 'bash', { + command: 'without optional sandbox facts', + description: 'exercise optional sandbox facts', + }) + + if (result.isError) throw new Error('expected foreground bash success') + expect(result.value).toMatchObject({ + kind: 'foreground', + sandbox: { mode: 'read-only', denied: false }, + }) + expect((result.value as { sandbox: object }).sandbox).not.toHaveProperty('enforcement') + expect((result.value as { sandbox: object }).sandbox).not.toHaveProperty('runnerFailed') + }) + it('keeps the exhaustiveness backstop for a rogue approval implementation', async () => { const { ctx } = await setupSandboxed(true) ctx.approval.request = () => Promise.resolve('rogue' as ApprovalOutcome) diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index e899979f46..273bfb3987 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -4,7 +4,7 @@ import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-a import { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -111,7 +111,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TokenMeterService, { contextWindow: 400 }) ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps)) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'work', description: 'does work', parameters: { i: { type: 'number' } }, diff --git a/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts b/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts index bc382c8e4e..0d3478b1c0 100644 --- a/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts +++ b/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts @@ -151,7 +151,7 @@ describe('ToolResultPruneService session transaction', () => { text: 'x'.repeat(100), }], { isError: true, - error: { name: 'ExitError', code: 'EXIT_1' }, + error: { message: 'exit 1', info: { name: 'ExitError', code: 'EXIT_1' } }, meta: { diff: ['a', 'b'] }, futureField: { nested: true }, }) @@ -180,7 +180,7 @@ describe('ToolResultPruneService session transaction', () => { step: 1, callId: CallId('one'), isError: true, - error: { name: 'ExitError', code: 'EXIT_1' }, + error: { message: 'exit 1', info: { name: 'ExitError', code: 'EXIT_1' } }, meta: { diff: ['a', 'b'] }, futureField: { nested: true }, }, diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 06ae13818d..d69a7bb2ed 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -6,7 +6,7 @@ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as timeContext from '@deepseek-ai/dsh-time-context' @@ -387,7 +387,7 @@ describe('real agent-loop request history', () => { it('persists one ordered context per request, accumulates readings, and leaves system headers unchanged', async () => { const adapter = new ScriptedAdapter([toolCallResponse(), textResponse('done')]) const ctx = await loopHarness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'tick', description: 'advance fake time', parameters: {}, diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts index 21ef979459..77cef391b6 100644 --- a/packages/context/workspace-context/src/index.ts +++ b/packages/context/workspace-context/src/index.ts @@ -128,8 +128,7 @@ export function apply(ctx: Context, config: Config): void { if (update === undefined) return downstream pendingVersionUpdates.set(exec.token, update.versionUpdates) return { - kind: 'accept', - ...downstream.content !== undefined ? { content: downstream.content } : {}, + ...downstream, additionalContexts: [update.context, ...downstream.additionalContexts ?? []], } }) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 8b0320bcfc..8d1fa4a339 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -22,7 +22,7 @@ import type { } from '@deepseek-ai/dsh-fs' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import type { ToolExecution, ToolExecutionToken } from '@deepseek-ai/dsh-tools' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import { @@ -800,6 +800,7 @@ describe('workspace context request injection', () => { agent: stubAgent('/virtual/repo'), }), { isError: false, + value: null, content: [{ type: 'text', text: 'file content' }], }, async () => ({ kind: 'accept', @@ -835,7 +836,8 @@ describe('workspace context request injection', () => { agent, }) const result = { - isError: false, + isError: false as const, + value: null, content: [{ type: 'text' as const, text: 'hello' }], } @@ -1589,7 +1591,7 @@ describe('dynamic nested workspace context injection', () => { await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('workspace-context-abort'), { provider: 'mock', model: 'mock' }, { cwd: root }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'abort_step', description: 'Abort the current test step.', parameters: {}, @@ -1660,6 +1662,7 @@ describe('dynamic nested workspace context injection', () => { const pending = ctx.waterfall('tools/post-execute', exec, { content: [{ type: 'text', text: 'ok' }], isError: false, + value: null, }, () => Promise.resolve({ kind: 'accept' as const })) await expect(pending).rejects.toBe(reason) @@ -2380,7 +2383,8 @@ describe('dynamic nested workspace context injection', () => { const result = { callId: CallId('provider-probe-result'), content: [{ type: 'text' as const, text: 'ok' }], - isError: false, + isError: false as const, + value: null, } const failedStat = await ctx.waterfall('tools/post-execute', stubToolExecution({ @@ -2429,7 +2433,7 @@ describe('dynamic nested workspace context injection', () => { } }) - it('preserves nested and downstream post-execute contexts as separate entries', async () => { + it('preserves a downstream canonical value replacement and keeps contexts separate', async () => { const root = await tempRepo() const home = await tempRepo() try { @@ -2440,7 +2444,12 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, - content: [{ type: 'text' as const, text: 'downstream replacement' }], + value: { + path: 'pkg/deep/file.txt', + offset: 1, + lines: [{ number: 1, text: 'downstream replacement' }], + totalLines: 1, + }, additionalContexts: [{ content: [{ type: 'text' as const, text: 'downstream context' }], source: { kind: 'plugin' as const, plugin: 'downstream' }, @@ -2454,7 +2463,15 @@ describe('dynamic nested workspace context injection', () => { agent: stubAgent(root), }) - expect(blocksText(result.content)).toBe('downstream replacement') + expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected read replacement success') + expect(result.value).toEqual({ + path: 'pkg/deep/file.txt', + offset: 1, + lines: [{ number: 1, text: 'downstream replacement' }], + totalLines: 1, + }) + expect(blocksText(result.content)).toContain('downstream replacement') expect(result.additionalContexts).toHaveLength(2) expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' }) expect(workspaceContextOf(result)?.meta).toMatchObject({ @@ -2568,7 +2585,7 @@ describe('dynamic nested workspace context injection', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(ToolFs) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'composite-read', description: 'read through a nested dispatch', parameters: {}, @@ -2620,7 +2637,7 @@ describe('dynamic nested workspace context injection', () => { await ctx.plugin(workspaceContext, { maxBytes: 65536 }) const agent = stubAgent('/') const parent = Symbol('parent') as ToolExecutionToken - const plainResult = { callId: CallId('plain'), content: [], isError: false } + const plainResult = { callId: CallId('plain'), content: [], isError: false as const, value: null } ctx.emit('tools/result', stubToolExecution({ callId: CallId('agentless-child'), name: 'read', arguments: {}, parent, @@ -2658,7 +2675,8 @@ describe('dynamic nested workspace context injection', () => { const result = { callId: CallId('manual'), content: [{ type: 'text' as const, text: 'manual result' }], - isError: false, + isError: false as const, + value: null, } const cases = [ { name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, agent: undefined }, diff --git a/packages/cordis/tool-cordis/README.md b/packages/cordis/tool-cordis/README.md index 7819cb8c2c..51a6850fad 100644 --- a/packages/cordis/tool-cordis/README.md +++ b/packages/cordis/tool-cordis/README.md @@ -10,6 +10,8 @@ The self-referential cordis toolset: three model-facing tools over the live runt Exact model-facing schemas: [the generated tool catalog](../../../docs/tool-catalog.md). +Canonical successes are the inspection string, mount `{ id, pluginName, state, provides, waitingFor }`, and unmount `{ id, pluginName }`. Native renderers preserve the existing prose, so programs can use `mounted.id` while ordinary function calling still sees `mounted dyn-1 (...)`. + ## Trust stance The sandbox isolates globals but is not a security boundary. Node globals are absent or redirect to Cordis services such as `ctx.fs`, `ctx.web`, and `ctx.bash`, and writes to `globalThis` stay local, but host-realm helpers make escape possible. Mounted plugins receive a façade without framework internals, yet its allowed services affect the live runtime. Treat this toolset like bash access; see the [design and trust stance](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 23ffa88923..e8ddc86f83 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1450,7 +1450,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionEventMap', - declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': {\n content: ContentBlock[];\n source: MessageSource;\n };\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n };\n \'steering/message\': {\n turn: number;\n content: ContentBlock[];\n source: MessageSource;\n };\n \'todo/write\': {\n /* …truncated — full shape in source */', + declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': {\n content: ContentBlock[];\n source: MessageSource;\n };\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n message: string;\n info?: {\n name: string;\n code: string;\n };\n };\n meta?: JsonValue;\n };\n \'steering/message\': {\n turn: number;\n content: Content /* …truncated — full shape in source */', }, { name: 'SessionEventReadRequest', @@ -1674,20 +1674,20 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolDefinition', - declaration: 'export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n}', + declaration: 'export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n}', }, { name: 'ToolErrorInfo', declaration: 'export interface ToolErrorInfo {\n name: string;\n code: string;\n}', }, - { - name: 'ToolExecuteReturn', - declaration: 'export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n};', - }, { name: 'ToolExecution', declaration: 'export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n}', }, + { + name: 'ToolExecutionFailure', + declaration: 'export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n}', + }, { name: 'ToolExecutionInput', declaration: 'export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n}', @@ -1698,16 +1698,28 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolExecutionResult', - declaration: 'export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n}', + declaration: 'export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;', + }, + { + name: 'ToolExecutionSuccess', + declaration: 'export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n}', }, { name: 'ToolExecutionToken', declaration: 'export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n};', }, + { + name: 'ToolFailure', + declaration: 'export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n}', + }, { name: 'ToolGuard', declaration: 'export type ToolGuard = (execution: Readonly) => string | undefined;', }, + { + name: 'ToolOutputDefinition', + declaration: 'export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n}', + }, { name: 'ToolProviderResult', declaration: 'export interface ToolProviderResult {\n readonly schemas: readonly ToolSchema[];\n readonly knownNames?: readonly string[];\n}', @@ -1718,7 +1730,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolResult', - declaration: 'export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n}', + declaration: 'export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n}', }, { name: 'ToolResultBlock', diff --git a/packages/cordis/tool-cordis/src/fiber-state.ts b/packages/cordis/tool-cordis/src/fiber-state.ts index 8c9f0e2f45..dcd9da149b 100644 --- a/packages/cordis/tool-cordis/src/fiber-state.ts +++ b/packages/cordis/tool-cordis/src/fiber-state.ts @@ -21,11 +21,11 @@ export const FiberState = { export type FiberState = FiberStateEnum /** Human-readable label for each {@link FiberState}, keyed by member (inlining-safe — no reverse mapping). */ -export const STATE_LABELS: Record = { +export const STATE_LABELS = { [FiberState.PENDING]: 'pending', [FiberState.LOADING]: 'loading', [FiberState.ACTIVE]: 'active', [FiberState.FAILED]: 'failed', [FiberState.DISPOSED]: 'disposed', [FiberState.UNLOADING]: 'unloading', -} +} as const satisfies Record diff --git a/packages/cordis/tool-cordis/src/guard.ts b/packages/cordis/tool-cordis/src/guard.ts index c55fd34b78..47cb8a7a53 100644 --- a/packages/cordis/tool-cordis/src/guard.ts +++ b/packages/cordis/tool-cordis/src/guard.ts @@ -6,8 +6,8 @@ * return values with. The façade is a whitelist of lifecycle-safe verbs and declared services; * framework internals and context-valued service returns are denied. * - * VM-realm schemas are rebuilt as host objects, and tool results are JSON-round-tripped and - * shape-checked before session logging. Common JSON-Schema spellings are normalized when they + * VM-realm schemas and canonical values are rebuilt as host objects, while rendered content and + * presentation metadata are shape-checked before entering the registry. Common JSON-Schema spellings are normalized when they * have one meaning; invalid vocabulary fails during registration with a teaching error. * @module @deepseek-ai/dsh-tool-cordis/guard */ @@ -16,7 +16,9 @@ import { Context } from 'cordis' import type { Plugin } from 'cordis' import { scopeOf } from '@deepseek-ai/dsh-scope' import { assertSupportedJsonSchema, defineTool } from '@deepseek-ai/dsh-tools' -import type { ToolDefinition, ToolExecuteReturn } from '@deepseek-ai/dsh-tools' +import type { ToolDefinition } from '@deepseek-ai/dsh-tools' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { JsonValue } from '@deepseek-ai/dsh-session' const DYNAMIC_TOOL = Symbol('tool-cordis.dynamic-tool') const SCHEMA_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'null', 'object', 'array', 'json']) @@ -254,34 +256,23 @@ const RETURN_PREVIEW_LIMIT = 120 * (`String(…)` for the un-stringifiable undefined case), truncated to * {@link RETURN_PREVIEW_LIMIT}. */ -function describeReturn(value: unknown): string { - // JSON.stringify is TYPED as always returning string, but it yields - // undefined for an undefined input (the routed forgot-return case) — the - // assertion widens the type back to the runtime truth. - const json = JSON.stringify(value) as string | undefined - if (json === undefined) return String(value) +function describeReturn(value: JsonValue): string { + // The caller has already crossed cloneJson, so this value is lossless JSON + // and serialization cannot produce undefined. + const json = JSON.stringify(value) return json.length > RETURN_PREVIEW_LIMIT ? `${json.slice(0, RETURN_PREVIEW_LIMIT)}…` : json } /** - * Validate a round-tripped `execute` return against the two shapes - * {@link ToolExecuteReturn} allows: an ARRAY of content blocks, or - * `{ content: blocks, meta? }`. The registry trusts the shape blindly — it - * spreads `result.content`, so an unvalidated `{ content: 'ok' }` would enter - * the session log as `['o','k']` and silently corrupt the next model request — - * so a wrong shape fails THIS call with a teaching error instead. + * Validate and host-materialize a sandbox renderer's content blocks. */ -function assertExecuteReturn(value: unknown): ToolExecuteReturn { +function assertRenderedContent(value: JsonValue): ContentBlock[] { if (Array.isArray(value) && value.every(isContentBlockShape)) { - return value as ToolExecuteReturn - } - if (isPlainRecord(value) && Array.isArray(value.content) && value.content.every(isContentBlockShape)) { - return value as ToolExecuteReturn + return value as unknown as ContentBlock[] } throw new Error( - `execute returned ${describeReturn(value)} — a tool's execute must return an ARRAY of content blocks, never a bare string:\n` - + ' ✓ return [{ type: \'text\', text: someString }]\n' - + ' ✓ return { content: [{ type: \'text\', text: someString }], meta: anyJsonValue }', + `output.render returned ${describeReturn(value)} — it must return an ARRAY of content blocks:\n` + + ' ✓ return [{ type: \'text\', text: String(value) }]', ) } @@ -294,23 +285,46 @@ function assertExecuteReturn(value: unknown): ToolExecuteReturn { * @param options - the standard `defineTool` options; `parameters` may be the ParameterSchemaSpec DSL or a JSON-Schema-style wrapper. * @returns the marker-tagged definition `harness.registerTool` (and the guarded `ctx.tools.register`) accepts. */ -export function sandboxDefineTool(options: Parameters[0]): ToolDefinition { - const normalized = normalizeParameterSchemaSpec((options as { parameters?: unknown }).parameters) - const tool = defineTool({ ...options, parameters: normalized.spec } as Parameters[0]) +export function sandboxDefineTool(options: unknown): ToolDefinition { + if (!isPlainRecord(options)) throw new Error('harness.defineTool options must be an object') + const normalized = normalizeParameterSchemaSpec(options.parameters) + if (!isPlainRecord(options.output)) { + throw new Error('harness.defineTool output must declare { schema, render, presentationMeta? }') + } + const output = options.output + if (typeof output.render !== 'function') throw new Error('harness.defineTool output.render must be a function') + if (output.presentationMeta !== undefined && typeof output.presentationMeta !== 'function') { + throw new Error('harness.defineTool output.presentationMeta must be a function when present') + } + if (typeof options.execute !== 'function') throw new Error('harness.defineTool execute must be a function') + const schema = normalizeValueSchema(output.schema, 'output.schema') + const rawExecute = options.execute as (args: unknown, exec: unknown) => Promise + const rawRender = output.render as (args: unknown, value: unknown) => unknown + const rawPresentationMeta = output.presentationMeta as ((args: unknown, value: unknown) => unknown) | undefined + const erasedDefineTool = defineTool as unknown as (definition: unknown) => ToolDefinition + const tool = erasedDefineTool({ + ...options, + parameters: normalized.spec, + output: { + schema, + render(args: unknown, value: unknown): ContentBlock[] { + return assertRenderedContent(cloneJson(rawRender(args, value), 'output.render result') as JsonValue) + }, + ...rawPresentationMeta !== undefined ? { + presentationMeta(args: unknown, value: unknown): JsonValue { + return cloneJson(rawPresentationMeta(args, value), 'output.presentationMeta result') as JsonValue + }, + } : {}, + }, + async execute(args: unknown, exec: unknown): Promise { + return cloneJson(await rawExecute(args, exec), 'execute result') as JsonValue + }, + }) const parameters = { ...tool.parameters, ...normalized.rootAnnotations } assertSupportedJsonSchema(parameters) - const execute = tool.execute.bind(tool) return markDynamicTool({ ...tool, parameters, - async execute(args, exec) { - // JSON.stringify yields NO JSON for an undefined (or function/symbol) - // return despite its string-typed signature — route that into - // assertExecuteReturn's teaching error rather than letting JSON.parse - // throw its cryptic '"undefined" is not valid JSON'. - const json = JSON.stringify(await execute(args, exec)) as string | undefined - return assertExecuteReturn(json === undefined ? undefined : JSON.parse(json) as unknown) - }, }) } diff --git a/packages/cordis/tool-cordis/src/index.ts b/packages/cordis/tool-cordis/src/index.ts index 9ac29aeb07..15fd735468 100644 --- a/packages/cordis/tool-cordis/src/index.ts +++ b/packages/cordis/tool-cordis/src/index.ts @@ -13,7 +13,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools' import { STATE_LABELS } from './fiber-state.ts' import { isPlugin, pluginName } from './guard.ts' import { EVENT_API, INHERITED_CTX_API, SERVICE_API, TYPE_API } from './api-catalog.ts' -import { describeApi, describeDynamic, describeEvents, describePlugins, describeServices, describeTools } from './inspect.ts' +import { describeApi, describeDynamic, describeEvents, describePlugins, describeServices, describeTools, providedServices } from './inspect.ts' import { missingServices, mountDynamic, type DynamicMount } from './mount.ts' import { presentInspectCall, presentMountCall, presentUnmountCall } from './present.ts' import { createSandbox, evaluateMountCode } from './sandbox.ts' @@ -76,7 +76,11 @@ export function apply(ctx: Context, config: Config): void { description: 'Exact service key or event name whose original JSDoc to include; valid only with what:"api" or what:"events".', }, }, - execute(args, exec): Promise<{ type: 'text'; text: string }[]> { + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, + execute(args, exec): Promise { if (args.name !== undefined && args.what !== 'api' && args.what !== 'events') { throw new Error('name is valid only with what:"api" or what:"events"') } @@ -94,7 +98,7 @@ export function apply(ctx: Context, config: Config): void { const text = selected .map(([heading, body]) => `## ${heading}\n${body().join('\n')}`) .join('\n\n') - return Promise.resolve([{ type: 'text', text }]) + return Promise.resolve(text) }, presentCall: presentInspectCall, })) @@ -119,13 +123,14 @@ export function apply(ctx: Context, config: Config): void { + 'Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe ' + 'events (see cordis_inspect what:"events"), or call ' + '`harness.registerTool(ctx, harness.defineTool({ name, description, parameters: ' - + '{ text: { type: \'string\', required: true } }, async execute(args) { … } }))` ' + + '{ text: { type: \'string\', required: true } }, output: { schema: { type: \'string\' }, ' + + 'render(_args, value) { return [{ type: \'text\', text: value }] } }, async execute(args) { return args.text } }))` ' + 'to give yourself a new tool — it becomes callable on your NEXT step. ' + 'Tool parameters: each key IS a property — { type: \'string\'|\'number\'|\'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. ' + + 'tool\'s `execute` MUST return the lossless JSON value declared by `output.schema`; ' + + '`output.render(args, value)` separately returns Native/model content blocks. ' + 'Mounts can COMPOSE: one plugin may `ctx.provide(\'name\', value)` a service and ' + 'another may declare `inject: [\'name\']` to consume it — the consumer stays pending ' + 'until the provider exists and returns to pending when the provider is unmounted. ' @@ -157,6 +162,32 @@ export function apply(ctx: Context, config: Config): void { description: 'Body of an async JS function; must `return` the plugin to mount.', }, }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + id: { type: 'string', required: true }, + pluginName: { type: 'string', required: true }, + state: { + type: 'string', + required: true, + enum: ['pending', 'loading', 'active', 'failed', 'disposed', 'unloading'], + }, + provides: { type: 'array', required: true, items: { type: 'string' } }, + waitingFor: { type: 'array', required: true, items: { type: 'string' } }, + }, + }, + render: (_args, value) => { + const note = value.waitingFor.length > 0 + ? ` — waiting for service(s): ${value.waitingFor.join(', ')} (activates when provided)` + : '' + return [{ + type: 'text', + text: `mounted ${value.id} (plugin "${value.pluginName}", state: ${value.state}${note})`, + }] + }, + }, async execute(args) { const id = `dyn-${nextId++}` const sandbox = createSandbox(id) @@ -180,10 +211,13 @@ export function apply(ctx: Context, config: Config): void { // it mounted but tell the model what it is waiting for. const missing = missingServices(ctx, fiber) const state = STATE_LABELS[fiber.state] - const note = missing.length > 0 - ? ` — waiting for service(s): ${missing.join(', ')} (activates when provided)` - : '' - return [{ type: 'text', text: `mounted ${id} (plugin "${pluginName(evaluated)}", state: ${state}${note})` }] + return { + id, + pluginName: pluginName(evaluated), + state, + provides: providedServices(ctx, fiber), + waitingFor: missing, + } }, presentCall: presentMountCall, })) @@ -202,6 +236,17 @@ export function apply(ctx: Context, config: Config): void { description: 'The dynamic mount id returned by cordis_mount (e.g. "dyn-1").', }, }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + id: { type: 'string', required: true }, + pluginName: { type: 'string', required: true }, + }, + }, + render: (_args, value) => [{ type: 'text', text: `unmounted ${value.id} (plugin "${value.pluginName}")` }], + }, async execute(args) { const mount = mounts.get(args.id) if (!mount) { @@ -209,7 +254,7 @@ export function apply(ctx: Context, config: Config): void { } await mount.fiber.dispose() mounts.delete(args.id) - return [{ type: 'text', text: `unmounted ${args.id} (plugin "${mount.pluginName}")` }] + return { id: args.id, pluginName: mount.pluginName } }, presentCall: presentUnmountCall, })) diff --git a/packages/cordis/tool-cordis/src/inspect.ts b/packages/cordis/tool-cordis/src/inspect.ts index 7196f370ce..cdcad29699 100644 --- a/packages/cordis/tool-cordis/src/inspect.ts +++ b/packages/cordis/tool-cordis/src/inspect.ts @@ -33,8 +33,13 @@ function withinFiber(fiber: Fiber, root: Fiber): boolean { } } -/** The service names provided by a mount's fiber subtree, sorted. */ -function providedBy(ctx: Context, fiber: Fiber): string[] { +/** + * Return the service names provided by a mount's fiber subtree. + * @param ctx - the runtime whose service registrations are inspected. + * @param fiber - the root of the mounted fiber subtree. + * @returns the provided service names in lexical order. + */ +export function providedServices(ctx: Context, fiber: Fiber): string[] { return liveImpls(ctx) .filter(impl => withinFiber(impl.fiber, fiber)) .map(impl => impl.name) @@ -96,7 +101,7 @@ export function describeTools(ctx: Context, scope?: ScopeKey): string[] { export function describeDynamic(ctx: Context, mounts: ReadonlyMap): string[] { if (mounts.size === 0) return ['(no dynamic plugins mounted)'] return [...mounts].map(([id, mount]) => { - const provides = providedBy(ctx, mount.fiber) + const provides = providedServices(ctx, mount.fiber) const waiting = missingServices(ctx, mount.fiber) const providesNote = provides.length > 0 ? ` — provides: ${provides.join(', ')}` : '' const waitingNote = waiting.length > 0 ? ` — waiting for: ${waiting.join(', ')}` : '' diff --git a/packages/cordis/tool-cordis/tests/cross-mount.spec.ts b/packages/cordis/tool-cordis/tests/cross-mount.spec.ts index dcfdb815c4..b2e515b4c8 100644 --- a/packages/cordis/tool-cordis/tests/cross-mount.spec.ts +++ b/packages/cordis/tool-cordis/tests/cross-mount.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { call, CONSUMER_CODE, PROVIDER_CODE, setup, text } from './helpers.ts' +import { call, CONSUMER_CODE, CONTENT_OUTPUT_CODE, PROVIDER_CODE, setup, text } from './helpers.ts' /** * Cross-mount composition through ordinary cordis provide/inject semantics: @@ -116,6 +116,7 @@ describe('cross-mount provide/inject', () => { name: 'answer', description: 'Read the provided primitive services.', parameters: {}, + ${CONTENT_OUTPUT_CODE} async execute() { return [{ type: 'text', text: ctx.answer + '/' + ctx.get('answer') + '/' + ctx.nothing }] }, diff --git a/packages/cordis/tool-cordis/tests/helpers.ts b/packages/cordis/tool-cordis/tests/helpers.ts index b183a2444f..6a681b93c6 100644 --- a/packages/cordis/tool-cordis/tests/helpers.ts +++ b/packages/cordis/tool-cordis/tests/helpers.ts @@ -45,6 +45,13 @@ export const LISTENER_CODE = ` } ` +/** Explicit content-array output declaration for dynamic-tool behavior fixtures. */ +export const CONTENT_OUTPUT_CODE = ` + output: { + schema: { type: 'array', items: { type: 'json' } }, + render(_args, value) { return value }, + },` + /** Mount code for a self-made tool: registers `reverse_text` via the sandbox's harness helpers. */ export const REVERSE_TOOL_CODE = ` return { @@ -55,8 +62,14 @@ export const REVERSE_TOOL_CODE = ` name: 'reverse_text', description: 'Reverse a string.', parameters: { text: { type: 'string', required: true } }, + output: { + schema: { type: 'string' }, + render(_args, value) { + return [{ type: 'text', text: value }] + }, + }, async execute(args) { - return [{ type: 'text', text: args.text.split('').reverse().join('') }] + return args.text.split('').reverse().join('') }, })) }, @@ -83,8 +96,14 @@ export const CONSUMER_CODE = ` name: 'greet', description: 'Greet someone via the greeter service.', parameters: { name: { type: 'string', required: true } }, + output: { + schema: { type: 'string' }, + render(_args, value) { + return [{ type: 'text', text: value }] + }, + }, async execute(args) { - return [{ type: 'text', text: ctx.greeter.greet(args.name) }] + return ctx.greeter.greet(args.name) }, })) }, @@ -97,8 +116,9 @@ export function dummyTool(name: string): ToolDefinition { name, description: 'test trigger', parameters: { type: 'object' as const, properties: {} }, - async execute(): Promise<[]> { - return [] + output: { schema: { type: 'null' }, render: () => [] }, + async execute(): Promise { + return null }, } } diff --git a/packages/cordis/tool-cordis/tests/inspect.spec.ts b/packages/cordis/tool-cordis/tests/inspect.spec.ts index 4d986d4f3e..8eaa154535 100644 --- a/packages/cordis/tool-cordis/tests/inspect.spec.ts +++ b/packages/cordis/tool-cordis/tests/inspect.spec.ts @@ -16,6 +16,8 @@ describe('cordis_inspect', () => { const result = await call(ctx, 'cordis_inspect', {}) expect(result.isError).toBe(false) const report = text(result) + if (result.isError) throw new Error('expected cordis_inspect success') + expect(result.value).toBe(report) for (const heading of ['services', 'plugins', 'tools', 'dynamic', 'api', 'events']) { expect(report).toContain(`## ${heading}`) } diff --git a/packages/cordis/tool-cordis/tests/mount.spec.ts b/packages/cordis/tool-cordis/tests/mount.spec.ts index 42af6fc64e..6d98cb33a3 100644 --- a/packages/cordis/tool-cordis/tests/mount.spec.ts +++ b/packages/cordis/tool-cordis/tests/mount.spec.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { isJsonValue } from '@deepseek-ai/dsh-session' +import { sandboxDefineTool } from '../src/guard.ts' import { syntaxErrorContext } from '../src/sandbox.ts' -import { call, dummyTool, LISTENER_CODE, REVERSE_TOOL_CODE, setup, text } from './helpers.ts' +import { call, CONTENT_OUTPUT_CODE, dummyTool, LISTENER_CODE, REVERSE_TOOL_CODE, setup, text } from './helpers.ts' /** * The `cordis_mount` success/failure family: real plugins land on a genuine @@ -14,12 +15,48 @@ afterEach(() => { }) describe('cordis_mount', () => { + it.each([ + [42, 'options must be an object'], + [{ parameters: {} }, 'output must declare { schema, render, presentationMeta? }'], + [{ parameters: {}, output: { schema: { type: 'json' } }, execute: async (): Promise => null }, 'output.render must be a function'], + [{ parameters: {}, output: { schema: { type: 'json' }, render: () => [] }, execute: true }, 'execute must be a function'], + [{ + parameters: {}, + output: { schema: { type: 'json' }, render: () => [], presentationMeta: true }, + execute: async (): Promise => null, + }, 'output.presentationMeta must be a function'], + ])('rejects an invalid dynamic tool declaration before registration: %j', (definition, message) => { + expect(() => sandboxDefineTool(definition)).toThrow(message) + }) + + it('bounds the preview of an invalid dynamic renderer return', () => { + const definition = sandboxDefineTool({ + name: 'invalid-renderer', + description: 'invalid renderer', + parameters: {}, + output: { + schema: { type: 'string' }, + render: () => ['x'.repeat(500)], + }, + execute: async () => 'ok', + }) + expect(() => definition.output.render({}, 'ok')).toThrow(/output\.render returned \["x+…/) + }) + it('mounts a listener plugin that observes real events, tagged-logging through to the host console', async () => { const ctx = await setup() const log = vi.spyOn(console, 'log').mockImplementation(() => {}) const result = await call(ctx, 'cordis_mount', { code: LISTENER_CODE }) expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected cordis_mount success') + expect(result.value).toEqual({ + id: 'dyn-1', + pluginName: 'change-logger', + state: 'active', + provides: [], + waitingFor: [], + }) expect(text(result)).toContain('mounted dyn-1 (plugin "change-logger", state: active)') // Fire a REAL tools/change by registering a tool; the mounted listener logs. @@ -44,6 +81,8 @@ describe('cordis_mount', () => { expect(ctx.tools.schemas().map(schema => schema.name)).toContain('reverse_text') const reversed = await call(ctx, 'reverse_text', { text: 'harness' }) expect(reversed.isError).toBe(false) + if (reversed.isError) throw new Error('expected dynamic tool success') + expect(reversed.value).toBe('ssenrah') expect(text(reversed)).toBe('ssenrah') }) @@ -55,7 +94,7 @@ describe('cordis_mount', () => { expect(isJsonValue({ content: reversed.content, isError: reversed.isError })).toBe(true) }) - it('threads the { content, meta } object return form through to the registry result', async () => { + it('projects presentation metadata from a dynamic canonical value', async () => { const ctx = await setup() await call(ctx, 'cordis_mount', { code: ` @@ -67,8 +106,13 @@ describe('cordis_mount', () => { name: 'meta_tool', description: 'attaches a private presentation payload', parameters: {}, + output: { + schema: { type: 'string' }, + render(_args, value) { return [{ type: 'text', text: value }] }, + presentationMeta() { return { kind: 'demo' } }, + }, async execute() { - return { content: [{ type: 'text', text: 'ok' }], meta: { kind: 'demo' } } + return 'ok' }, })) }, @@ -77,20 +121,20 @@ describe('cordis_mount', () => { }) const result = await call(ctx, 'meta_tool', {}) expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected dynamic tool success') + expect(result.value).toBe('ok') expect(text(result)).toBe('ok') expect(result.meta).toEqual({ kind: 'demo' }) }) it.each([ - ['a bare string', 'return \'ok\'', '"ok"'], - ['an object whose content is a string', 'return { content: \'ok\' }', '{"content":"ok"}'], - ['an array of non-objects', 'return [\'ok\']', '["ok"]'], - ['blocks missing the type tag', 'return [{ text: \'hi\' }]', '[{"text":"hi"}]'], - ['object-form blocks missing the type tag', 'return { content: [{ text: \'hi\' }] }', '{"content":[{"text":"hi"}]}'], - ['undefined — a forgotten return', 'return undefined', 'undefined'], - ])('rejects an execute return of %s as that one call\'s teaching error', async (_label, returnStatement, preview) => { - // The registry spreads result.content, so { content: 'ok' } would become ['o','k']; reject - // it as this call's error before it corrupts the next request. + ['a bare string', 'return \'ok\'', 'returned invalid output: "value" must be an array'], + ['an object whose content is a string', 'return { content: \'ok\' }', 'returned invalid output: "value" must be an array'], + ['an array of non-objects', 'return [\'ok\']', 'output.render returned ["ok"]'], + ['blocks missing the type tag', 'return [{ text: \'hi\' }]', 'output.render returned [{"text":"hi"}]'], + ['object-form blocks missing the type tag', 'return { content: [{ text: \'hi\' }] }', 'returned invalid output: "value" must be an array'], + ['undefined — a forgotten return', 'return undefined', 'execute result must be lossless JSON data'], + ])('rejects an execute return of %s against its declared output', async (_label, returnStatement, diagnostic) => { const ctx = await setup() await call(ctx, 'cordis_mount', { code: ` @@ -102,6 +146,7 @@ describe('cordis_mount', () => { name: 'bad_return_tool', description: 'returns a wrong shape', parameters: {}, + ${CONTENT_OUTPUT_CODE} async execute() { ${returnStatement} }, })) }, @@ -112,12 +157,10 @@ describe('cordis_mount', () => { expect(result.isError).toBe(true) expect(result.content).toHaveLength(1) expect(result.content[0]!.type).toBe('text') - expect(text(result)).toContain(`execute returned ${preview}`) - expect(text(result)).toContain('must return an ARRAY of content blocks') - expect(text(result)).toContain('✓ return { content: [{ type: \'text\', text: someString }], meta: anyJsonValue }') + expect(text(result)).toContain(diagnostic) }) - it('truncates a huge invalid execute return in the teaching error', async () => { + it('does not echo a huge schema-invalid canonical value in the diagnostic', async () => { const ctx = await setup() await call(ctx, 'cordis_mount', { code: ` @@ -129,6 +172,7 @@ describe('cordis_mount', () => { name: 'huge_return_tool', description: 'returns a huge wrong shape', parameters: {}, + ${CONTENT_OUTPUT_CODE} async execute() { return 'x'.repeat(500) }, })) }, @@ -137,7 +181,7 @@ describe('cordis_mount', () => { }) const result = await call(ctx, 'huge_return_tool', {}) expect(result.isError).toBe(true) - expect(text(result)).toContain('…') + expect(text(result)).toContain('returned invalid output') expect(text(result)).not.toContain('x'.repeat(200)) }) @@ -167,6 +211,7 @@ describe('cordis_mount', () => { }, required: ['text'], }, + ${CONTENT_OUTPUT_CODE} async execute(args) { return [{ type: 'text', text: args.text + ':' + (args.count ?? 0) }] }, })) }, @@ -215,6 +260,7 @@ describe('cordis_mount', () => { cfg: { type: 'object', properties: { label: { type: 'string' } }, required: ['label'] }, }, }, + ${CONTENT_OUTPUT_CODE} async execute(args) { return [{ type: 'text', text: args.cfg.label }] }, })) }, @@ -254,6 +300,7 @@ describe('cordis_mount', () => { closed: { type: 'object', additionalProperties: false }, count: { type: 'number', enum: [1, 2], const: 1 }, }, + ${CONTENT_OUTPUT_CODE} async execute(args) { return [{ type: 'text', text: String(args.choice) }] }, })) }, @@ -299,6 +346,7 @@ describe('cordis_mount', () => { choice: { oneOf: [{ type: 'boolean' }, { type: 'null' }] }, }, }, + ${CONTENT_OUTPUT_CODE} async execute() { return [] }, })) }, @@ -356,6 +404,7 @@ describe('cordis_mount', () => { name: 'bad_schema_tool', description: 'bad', ${parameters}, + ${CONTENT_OUTPUT_CODE} async execute() { return [] }, })) }, @@ -381,6 +430,7 @@ describe('cordis_mount', () => { item: { type: 'object', additionalProperties: true, required: true, properties: { label: { type: 'string', required: true } } }, tags: { type: 'array', items: { type: 'string' } }, }, + ${CONTENT_OUTPUT_CODE} async execute(args) { return [{ type: 'text', text: args.item.label }] }, })) }, @@ -404,6 +454,7 @@ describe('cordis_mount', () => { name: 'raw_dynamic_tool', description: 'raw', parameters: { type: 'object', properties: {} }, + ${CONTENT_OUTPUT_CODE} async execute() { return [] }, }) }, @@ -457,6 +508,14 @@ describe('cordis_mount', () => { code: 'return { name: \'waiter\', inject: [\'no-such-service\'], apply(ctx) {} }', }) expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected pending cordis_mount success') + expect(result.value).toEqual({ + id: 'dyn-1', + pluginName: 'waiter', + state: 'pending', + provides: [], + waitingFor: ['no-such-service'], + }) expect(text(result)).toContain('state: pending') expect(text(result)).toContain('waiting for service(s): no-such-service') // Unmounting a pending mount works like any other. @@ -517,6 +576,7 @@ describe('cordis_mount', () => { name: 'cordis_mount', description: 'dup', parameters: {}, + ${CONTENT_OUTPUT_CODE} async execute() { return [] }, })) }, @@ -663,6 +723,7 @@ describe('cordis_mount', () => { name: 'probe_instanceof', description: 'report instanceof checks across realms', parameters: { items: { type: 'array', required: true, items: { type: 'string' } } }, + ${CONTENT_OUTPUT_CODE} async execute(args) { const checks = { hostArray: args.items instanceof Array, diff --git a/packages/cordis/tool-cordis/tests/sandbox-context.spec.ts b/packages/cordis/tool-cordis/tests/sandbox-context.spec.ts index c27d34d4c3..05a33848c0 100644 --- a/packages/cordis/tool-cordis/tests/sandbox-context.spec.ts +++ b/packages/cordis/tool-cordis/tests/sandbox-context.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { call, setup, text } from './helpers.ts' +import { call, CONTENT_OUTPUT_CODE, setup, text } from './helpers.ts' /** * The sandbox context façade is a whitelist, not a pass-through proxy. Mounted code reaches only @@ -51,6 +51,7 @@ describe('sandbox context façade — escape surface is closed', () => { name: 'smuggled', description: 'raw, unguarded', parameters: { type: 'object', properties: {} }, + ${CONTENT_OUTPUT_CODE} async execute() { return [] }, }) }, @@ -87,6 +88,7 @@ describe('sandbox context façade — escape surface is closed', () => { name: 'smuggled_via_service', description: 'raw, unguarded', parameters: { type: 'object', properties: {} }, + ${CONTENT_OUTPUT_CODE} async execute() { return [] }, }) }, @@ -116,6 +118,7 @@ describe('sandbox context façade — escape surface is closed', () => { name: 'do_fetch', description: 'awaits the host async service', parameters: {}, + ${CONTENT_OUTPUT_CODE} async execute() { const value = await ctx.hostAsync.grab() return [{ type: 'text', text: value }] @@ -203,6 +206,7 @@ describe('sandbox context façade — inject gate on services', () => { name: 'greet_undeclared', description: 'uses greeter without declaring it', parameters: { n: { type: 'string', required: true } }, + ${CONTENT_OUTPUT_CODE} async execute(args) { return [{ type: 'text', text: ctx.greeter.greet(args.n) }] }, })) }, @@ -235,6 +239,7 @@ describe('sandbox tools façade — get is a read-only schema view', () => { name: 'report_view', description: 'reports the shape of a tool view', parameters: {}, + ${CONTENT_OUTPUT_CODE} async execute() { const view = ctx.tools.get('cordis_mount') return [{ type: 'text', text: JSON.stringify({ @@ -270,6 +275,7 @@ describe('sandbox tools façade — get is a read-only schema view', () => { name: 'probe_unknown', description: 'reports whether an unknown tool resolves', parameters: {}, + ${CONTENT_OUTPUT_CODE} async execute() { return [{ type: 'text', text: String(ctx.tools.get('no_such_tool') === undefined) }] }, diff --git a/packages/cordis/tool-cordis/tests/unmount-hmr.spec.ts b/packages/cordis/tool-cordis/tests/unmount-hmr.spec.ts index 718a213968..92e5ee7a66 100644 --- a/packages/cordis/tool-cordis/tests/unmount-hmr.spec.ts +++ b/packages/cordis/tool-cordis/tests/unmount-hmr.spec.ts @@ -26,6 +26,8 @@ describe('cordis_unmount', () => { const result = await call(ctx, 'cordis_unmount', { id: 'dyn-1' }) expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected cordis_unmount success') + expect(result.value).toEqual({ id: 'dyn-1', pluginName: 'change-logger' }) expect(text(result)).toContain('unmounted dyn-1') // Immediately after the awaited unmount, the listener must be gone — no diff --git a/packages/core/agent-loop/src/tool-calls.ts b/packages/core/agent-loop/src/tool-calls.ts index 663dda1b53..61a6d37947 100644 --- a/packages/core/agent-loop/src/tool-calls.ts +++ b/packages/core/agent-loop/src/tool-calls.ts @@ -219,7 +219,10 @@ function appendSkippedToolCall(session: Session, turn: number, step: number, blo appendToolResult(session, turn, step, block, { content: [{ type: 'text', text: 'Error: tool call skipped because the step was aborted before execution' }], isError: true, - error: { name: 'AbortError', code: 'ABORTED' }, + error: { + message: 'tool call skipped because the step was aborted before execution', + info: { name: 'AbortError', code: 'ABORTED' }, + }, }, callSeq) } diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index 8e9b951fa1..5ede648726 100644 --- a/packages/core/agent-loop/tests/agent-initiator.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -6,7 +6,7 @@ import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' interface Harness { @@ -156,7 +156,7 @@ describe('AgentLoop initiator scope', () => { let parentWhileChildDriverActive: Agent | undefined let child: Agent | undefined - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'spawn-child', description: 'create one child agent', parameters: {}, @@ -168,7 +168,7 @@ describe('AgentLoop initiator scope', () => { setup: (agentCtx) => { parentDuringSetup = ctx.agents.requireInitiator() explicitChild = agentCtx.agent - agentCtx.tools.register(defineTool({ + agentCtx.tools.register(defineContentToolFixture({ name: 'observe-child', description: 'observe child execution identity', parameters: {}, @@ -216,7 +216,7 @@ describe('AgentLoop initiator scope', () => { let directAmbient: Agent | undefined let captured: Agent | undefined - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'agentless-probe', description: 'observe an agentless call', parameters: {}, @@ -226,7 +226,7 @@ describe('AgentLoop initiator scope', () => { return [{ type: 'text', text: 'ok' }] }, })) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'capability-request', description: 'call the test capability transport', parameters: { path: { type: 'string' } }, diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index f6df171365..301ebbf992 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -12,7 +12,7 @@ import { Context } from 'cordis' import LlmService, { type Message } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -366,7 +366,7 @@ describe('Agent.cancel()', () => { ]) const ctx = await harness(adapter) let executions = 0 - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'danger', description: 'must not run after cancellation', parameters: {}, @@ -397,7 +397,7 @@ describe('Agent.cancel()', () => { expect(result?.type === 'tool/result' ? result.data : undefined).toMatchObject({ callId: 'c1', isError: true, - error: { name: 'AbortError', code: 'ABORTED' }, + error: { info: { name: 'AbortError', code: 'ABORTED' } }, }) send(agent, 'continue safely') diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 7ed8c2075e..17f12de400 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import LlmService, { CallId, ContentBlock, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool, type PostToolDecision } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture, type PostToolDecision } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent, type ContinuationDecision } from '@deepseek-ai/dsh-agent' import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop' import { prepareReactLoopAgent } from '../src/agent.ts' @@ -50,7 +50,7 @@ describe('session log records what agent/step-result actually produced', () => { const adapter = new MockAdapter([original, textResponse('done')]) const ctx = await harness(adapter) const executed: string[] = [] - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'injected-tool', description: '', parameters: {}, @@ -219,7 +219,7 @@ describe('abort during tool execution ends the turn', () => { const ctx = await harness(adapter) const executed: string[] = [] const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'aborter', description: '', parameters: {}, @@ -241,7 +241,7 @@ describe('abort during tool execution ends the turn', () => { source: { kind: 'plugin', plugin: 'abort-test' }, }], })) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'second', description: '', parameters: {}, @@ -259,7 +259,7 @@ describe('abort during tool execution ends the turn', () => { case 'assistant/message': order.push('assistant/message'); break case 'tool/call': order.push(`tool/call:${event.data.callId}`); break case 'tool/result': { - const outcome = event.data.error?.code === 'ABORTED' ? 'synthetic-aborted' : 'real' + const outcome = event.data.error?.info?.code === 'ABORTED' ? 'synthetic-aborted' : 'real' order.push(`tool/result:${event.data.callId}:${outcome}`) break } @@ -308,7 +308,7 @@ describe('abort during tool execution ends the turn', () => { expect(results[1]!.data).toMatchObject({ callId: CallId('c2'), isError: true, - error: { name: 'AbortError', code: 'ABORTED' }, + error: { info: { name: 'AbortError', code: 'ABORTED' } }, }) }) @@ -316,7 +316,7 @@ describe('abort during tool execution ends the turn', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'aborter', {})]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a-abort-injection'), { provider: 'mock', model: 'mock' }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'aborter', description: '', parameters: {}, @@ -362,7 +362,7 @@ describe('abort during tool execution ends the turn', () => { ] satisfies StreamChunk[]]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a-later-abort-context'), { provider: 'mock', model: 'mock' }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'first', description: '', parameters: {}, @@ -370,7 +370,7 @@ describe('abort during tool execution ends the turn', () => { return [{ type: 'text', text: 'first done' }] }, })) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'aborter', description: '', parameters: {}, @@ -411,7 +411,7 @@ describe('abort during tool execution ends the turn', () => { const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('a-dispose-injection'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'waiter', description: '', parameters: {}, @@ -463,7 +463,7 @@ describe('abort during tool execution ends the turn', () => { ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a-historical-tool-pair'), { provider: 'mock', model: 'mock' }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'aborter', description: '', parameters: {}, @@ -472,7 +472,7 @@ describe('abort during tool execution ends the turn', () => { return [{ type: 'text', text: 'done' }] }, })) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'second', description: '', parameters: {}, @@ -771,7 +771,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'noop', description: '', parameters: {}, @@ -837,7 +837,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { const agent = ctx.agentLoop.create(SessionId('owned-steer'), { provider: 'mock', model: 'mock' }) const entered = Promise.withResolvers() const release = Promise.withResolvers() - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'gate', description: '', parameters: {}, @@ -1423,7 +1423,7 @@ describe('tool result call identity', () => { textResponse('done'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'echo', parameters: { x: { type: 'number' } }, diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index ac7f301525..65c2258d0b 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -4,7 +4,7 @@ import LlmService, { CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -77,7 +77,7 @@ describe('tool JSON parse', () => { textResponse('done'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'echo tool', parameters: { input: { type: 'string' } }, @@ -110,7 +110,7 @@ describe('tool JSON parse', () => { textResponse('done'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'noarg', description: 'no-arg tool', parameters: {}, @@ -259,7 +259,7 @@ describe('structured tool error propagation (the runtime-validation Agent Note, ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'boom', description: 'always fails', parameters: {}, @@ -274,6 +274,6 @@ describe('structured tool error propagation (the runtime-validation Agent Note, const toolResult = agent.session.events.find(e => e.type === 'tool/result') expect(toolResult?.type === 'tool/result' && toolResult.data.isError).toBe(true) expect(toolResult?.type === 'tool/result' && toolResult.data.error) - .toEqual({ name: 'HarnessError', code: 'BOOM' }) + .toEqual({ message: 'exploded', info: { name: 'HarnessError', code: 'BOOM' } }) }) }) diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 83b156b0ef..3d4a5e67c5 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import LlmService, { CallId, type Message } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent, type ContinuationDecision, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -347,7 +347,7 @@ describe('agent/session-prefix', () => { textResponse('again'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) @@ -469,7 +469,7 @@ describe('agent/session-prefix', () => { textResponse('done'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) @@ -522,7 +522,7 @@ describe('agent/turn-continuation (ContinuationDecision)', () => { it('a stop decision ends the turn even when the step had tool calls', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'hi' })]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) @@ -552,7 +552,7 @@ describe('tool additionalContexts buffering across a step', () => { ] const adapter = new MockAdapter([twoCalls, textResponse('done')]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) @@ -594,7 +594,7 @@ describe('tool additionalContexts buffering across a step', () => { it('appends multiple contexts deferred by one composite tool after its outer result', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'composite', {}), textResponse('done')]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'composite', description: 'composite', parameters: {}, async execute(_args, exec) { exec.deferContext({ content: [{ type: 'text', text: 'nested-a' }], source: { kind: 'plugin', plugin: 'a' }, meta: { order: 1 } }) @@ -625,7 +625,7 @@ describe('tools/pre-execute gate (native-plugin permission pattern, end-to-end t const adapter = new MockAdapter([toolCallResponse('c1', 'danger', {}), textResponse('ok')]) const ctx = await harness(adapter) let ran = false - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'danger', description: 'danger', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] }, })) @@ -687,7 +687,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'hi' }), textResponse('done')]) const ctx = await harness(adapter) await ctx.plugin(NativeGuard) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index dd686edfcb..aa3a35bc36 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, TurnEndReason, type JsonValue } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture, defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -89,7 +89,7 @@ describe('agent loop', () => { textResponse('done'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'echo back', parameters: { text: { type: 'string' } }, @@ -118,22 +118,27 @@ describe('agent loop', () => { const types = agent.session.events.map(e => e.type) expect(types).toContain('tool/call') expect(types).toContain('tool/result') + const durableResult = agent.session.events.find(event => event.type === 'tool/result') + expect(durableResult?.type === 'tool/result' && 'value' in durableResult.data).toBe(false) }) - it('threads a tool-attached meta (execute object return) onto the tool/result event', async () => { + it('persists presentation metadata projected from the canonical value', async () => { const adapter = new MockAdapter([ toolCallResponse('c1', 'writer', { path: 'a.txt' }, 'writing'), textResponse('done'), ]) const ctx = await harness(adapter) - // A tool that returns the { content, meta } object form: the loop must - // persist `meta` on the tool/result event so a UI reproduces the card on replay. ctx.tools.register(defineTool({ name: 'writer', description: 'writes a file', parameters: { path: { type: 'string' } }, + output: { + schema: { type: 'string' }, + render: () => [{ type: 'text', text: 'ok' }], + presentationMeta: (_args, value) => ({ diffs: [{ path: value, oldText: null, newText: 'x' }] }), + }, async execute() { - return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a.txt', oldText: null, newText: 'x' }] } } + return 'a.txt' }, })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -152,7 +157,7 @@ describe('agent loop', () => { // projecting this agent's configured model, so the model knows its own name. const ctx = await harness(adapter, 'You are a test agent on {{model}}.') ctx.systemPrompt.section({ name: 'tool:noop', order: 100, text: 'Use the noop tool wisely.' }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'noop', description: 'does nothing', parameters: {}, @@ -248,7 +253,7 @@ describe('agent loop', () => { ['BigInt', { n: 1n }], ['Map', new Map([['key', 'value']])], ['class instance', new (class ResultMeta { x = 1 })()], - ])('normalizes non-JSON tool meta (%s) before the durable result commit', async (_kind, meta) => { + ])('normalizes non-JSON presentation metadata (%s) before the durable result commit', async (_kind, meta) => { const adapter = new MockAdapter([ toolCallResponse('bad-meta-call', 'bad-meta', {}, 'calling'), textResponse('recovered'), @@ -258,7 +263,12 @@ describe('agent loop', () => { name: 'bad-meta', description: 'returns invalid durable metadata', parameters: {}, - execute: () => Promise.resolve({ content: [{ type: 'text' as const, text: 'apparent success' }], meta }), + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + presentationMeta: () => meta as unknown as JsonValue, + }, + execute: () => Promise.resolve('apparent success'), })) const agent = ctx.agentLoop.create(SessionId('bad-meta-agent'), { provider: 'mock', model: 'mock' }) @@ -326,7 +336,7 @@ describe('agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'slow', description: '', parameters: {}, @@ -432,7 +442,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let visibleDuringTool = false const meta = { kind: 'deferred-test', version: 1 } - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'noticer', description: 'injects a notice', parameters: {}, @@ -494,7 +504,7 @@ describe('agent loop', () => { ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('invalid-context'), { provider: 'mock', model: 'mock' }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'invalid-injector', description: 'attempts an invalid context injection', parameters: {}, @@ -541,7 +551,7 @@ describe('agent loop', () => { it('agent/turn-continuation can veto continuation despite tool calls (budget-guard pattern)', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'x' })]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: '', parameters: { text: { type: 'string' } }, @@ -589,7 +599,7 @@ describe('agent loop', () => { textResponse('done'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'echo', parameters: {}, async execute() { return [{ type: 'text', text: 'echoed' }] }, })) @@ -781,7 +791,7 @@ describe('agent loop', () => { ]]) const ctx = await harness(adapter) let executions = 0 - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: '', parameters: { text: { type: 'string' } }, @@ -821,7 +831,7 @@ describe('agent loop', () => { { type: 'finish', reason: { kind: 'max-tokens' } }, ]]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: '', parameters: { text: { type: 'string' } }, @@ -908,7 +918,7 @@ describe('agent loop', () => { textResponse('continued after tool call'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: '', parameters: { text: { type: 'string' } }, @@ -1240,7 +1250,7 @@ describe('agent loop', () => { textResponse('done'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: '', parameters: { text: { type: 'string' } }, diff --git a/packages/core/agent-loop/tests/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts index 36d88feaa9..f1e1a1a367 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -45,7 +45,7 @@ async function loopHarness(): Promise { await created.plugin(AgentRegistry) await created.plugin(AgentLoop, { agents: [] }) await created.plugin(LlmDeepSeek) - created.tools.register(defineTool({ + created.tools.register(defineContentToolFixture({ name: 'lookup', description: 'Look up the stored value for a key.', parameters: { key: { type: 'string', description: 'The key to look up.' } }, diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 46cfe3eb56..755953c6f7 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -11,7 +11,7 @@ import LlmService from '@deepseek-ai/dsh-llm' import type { GenerateOptions } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -53,7 +53,7 @@ function expectPrefixExtension(previous: GenerateOptions, current: GenerateOptio } function registerEcho(ctx: Context) { - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'echo back', parameters: { text: { type: 'string' } }, diff --git a/packages/core/agent-loop/tests/request-recovery.spec.ts b/packages/core/agent-loop/tests/request-recovery.spec.ts index cf87d376ef..2bdd7e8d33 100644 --- a/packages/core/agent-loop/tests/request-recovery.spec.ts +++ b/packages/core/agent-loop/tests/request-recovery.spec.ts @@ -11,7 +11,7 @@ import LlmService, { import type { GenerateOptions, LlmFailure, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import type { PostToolDecision } from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -133,7 +133,7 @@ describe('agent post-step and request-error lifecycle', () => { ] const adapter = new FailureScriptAdapter([twoCalls, textResponse('done')]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'work', description: 'do work', parameters: {}, @@ -222,7 +222,7 @@ describe('agent post-step and request-error lifecycle', () => { textResponse('must not continue'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'work', description: 'do work', parameters: {}, @@ -535,7 +535,7 @@ describe('agent post-step and request-error lifecycle', () => { contextError('later overflow'), ]) const resetCtx = await harness(reset) - resetCtx.tools.register(defineTool({ + resetCtx.tools.register(defineContentToolFixture({ name: 'work', description: 'continue', parameters: {}, diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 73ee48f7a6..469a815d8b 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -3,7 +3,7 @@ import { Context, symbols, type EffectMeta, type Fiber } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry, { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -172,10 +172,10 @@ describe('agent scope lifecycle', () => { const handle = await ctx.agents.create({ sessionId: SessionId('s1'), agentOptions: { provider: 'mock', model: 'mock' } }) const { agent } = handle agent.ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You run tests.' }) - agent.ctx.tools.register({ + agent.ctx.tools.register(defineContentToolFixture({ name: 'mine', description: 'scoped', parameters: {}, execute: () => Promise.resolve(text('ran')), - }) + })) const scopedAssembly = await ctx.systemPrompt.assemble(assembleContextFor(agent)) expect(scopedAssembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You run tests.') @@ -587,12 +587,12 @@ describe('agent scope lifecycle', () => { sessionId: SessionId('dependency-origin-s'), agentOptions: { provider: 'mock', model: 'mock' }, setup: (agentCtx) => { - agentCtx.tools.register({ + agentCtx.tools.register(defineContentToolFixture({ name: 'dependency-origin-tool', description: 'proves AgentLoop dependency origin', parameters: {}, execute: () => Promise.resolve(text('ok')), - }) + })) agentCtx.systemPrompt.section({ name: 'dependency-origin-section', order: 1, diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index a77e1d678e..e13caa13c5 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -9,7 +9,7 @@ import { CallId, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import LlmService from '@deepseek-ai/dsh-llm' -import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -61,7 +61,7 @@ function multiCall(calls: { id: string; name: string; args: object }[]): StreamC function gatedTool(name: string, parallel: boolean) { const gates = new Map void>() const started: string[] = [] - const tool = defineTool({ + const tool = defineContentToolFixture({ name, description: `gated ${name}`, parameters: { id: { type: 'string', required: true } }, @@ -123,12 +123,12 @@ describe('tool-call scheduler: grouping and barriers', () => { textResponse('done'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'r', description: 'read', parameters: { id: { type: 'string', required: true } }, isConcurrencySafe: () => true, async execute(args) { order.push(`r-start-${args.id}`); order.push(`r-end-${args.id}`); return [{ type: 'text', text: 'r' }] }, })) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'w', description: 'write', parameters: { id: { type: 'string', required: true } }, async execute(args) { order.push(`w-${args.id}`); return [{ type: 'text', text: 'w' }] }, })) @@ -150,14 +150,14 @@ describe('tool-call scheduler: grouping and barriers', () => { ]) const ctx = await harness(adapter) const replacement = gatedExclusiveTool('x') - const disposeSafe = ctx.tools.register(defineTool({ + const disposeSafe = ctx.tools.register(defineContentToolFixture({ name: 'x', description: 'initially safe', parameters: { id: { type: 'string', required: true } }, isConcurrencySafe: () => true, async execute(args) { return [{ type: 'text', text: `old-${args.id}` }] }, })) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'replace', description: 'replace x', parameters: { id: { type: 'string', required: true } }, @@ -476,8 +476,22 @@ describe('tool-call scheduler: abort handling', () => { isError: e.data.isError, error: e.data.error, }))).toEqual([ - { callId: CallId('c1'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }, - { callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }, + { + callId: CallId('c1'), + isError: true, + error: { + message: 'tool call skipped because the step was aborted before execution', + info: { name: 'AbortError', code: 'ABORTED' }, + }, + }, + { + callId: CallId('c2'), + isError: true, + error: { + message: 'tool call skipped because the step was aborted before execution', + info: { name: 'AbortError', code: 'ABORTED' }, + }, + }, ]) }) @@ -509,7 +523,7 @@ describe('tool-call scheduler: abort handling', () => { expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId)) .toEqual([CallId('c1'), CallId('c2')]) expect(events(agent).filter(e => e.type === 'tool/result').at(-1)?.data) - .toMatchObject({ callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }) + .toMatchObject({ callId: CallId('c2'), isError: true, error: { info: { name: 'AbortError', code: 'ABORTED' } } }) }) it('stops replenishing after abort, commits started results, and drains accepted additional contexts', async () => { @@ -538,10 +552,14 @@ describe('tool-call scheduler: abort handling', () => { .toEqual([CallId('c1'), CallId('c2'), CallId('c3'), CallId('c4')]) expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId)) .toEqual([CallId('c1'), CallId('c2'), CallId('c3'), CallId('c4')]) - expect(events(agent).filter(e => e.type === 'tool/result').slice(-2).map(e => e.data)) + expect(events(agent).filter(e => e.type === 'tool/result').slice(-2).map(e => ({ + callId: e.data.callId, + isError: e.data.isError, + errorInfo: e.data.error?.info, + }))) .toEqual([ - expect.objectContaining({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }), - expect.objectContaining({ callId: CallId('c4'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }), + { callId: CallId('c3'), isError: true, errorInfo: { name: 'AbortError', code: 'ABORTED' } }, + { callId: CallId('c4'), isError: true, errorInfo: { name: 'AbortError', code: 'ABORTED' } }, ]) const settled = events(agent).filter(e => e.type === 'tool/result' || e.type === 'context/message') expect(settled.map(e => e.type)) @@ -564,7 +582,7 @@ describe('tool-call scheduler: abort handling', () => { const gated = gatedParallelTool('p') const exclusive: string[] = [] ctx.tools.register(gated.tool) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'x', description: 'exclusive', parameters: { id: { type: 'string', required: true } }, @@ -583,6 +601,6 @@ describe('tool-call scheduler: abort handling', () => { expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId)) .toEqual([CallId('c1'), CallId('c2'), CallId('c3')]) expect(events(agent).filter(e => e.type === 'tool/result').at(-1)?.data) - .toMatchObject({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }) + .toMatchObject({ callId: CallId('c3'), isError: true, error: { info: { name: 'AbortError', code: 'ABORTED' } } }) }) }) diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index bf78208a42..76b14e92c2 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -12,7 +12,7 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' import SystemPrompt, { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -42,7 +42,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { } function registerNamed(ctx: Context, name: string) { - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name, description: `the ${name} tool`, parameters: {}, diff --git a/packages/core/agent-loop/tests/turn-stop.spec.ts b/packages/core/agent-loop/tests/turn-stop.spec.ts index 355e1e8e3d..f7b59e3585 100644 --- a/packages/core/agent-loop/tests/turn-stop.spec.ts +++ b/packages/core/agent-loop/tests/turn-stop.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent, type ContinuationStop } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -29,7 +29,7 @@ function send(agent: Agent, text = 'go'): Promise { } function registerEcho(ctx: Context): void { - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 28210e8f6c..aaf1d429ee 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -58,6 +58,8 @@ Durable values need one accepted representation, not a check followed by a secon `context/message` renders its `content` verbatim as a user-role message, and may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`. +`tool/result` persists the model-facing content, canonical failure detail, and optional presentation metadata. A tool's successful canonical `value` is deliberately execution-local and never enters the session event, so replay reconstructs the Native/model presentation but cannot recover intermediate programmatic values. This does not change `SESSION_FORMAT_VERSION`: the persisted projection remains authoritative. + ### Session event vocabulary (`types.ts`) The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message. Provider/model/replay provenance rides on `assistant/message`; an operational error's step is on `turn/end.reason` for `kind: 'error'`, with structured provider facts for a final model-request failure. diff --git a/packages/core/session/src/repair.ts b/packages/core/session/src/repair.ts index efbb3d2004..fa3b8bbb50 100644 --- a/packages/core/session/src/repair.ts +++ b/packages/core/session/src/repair.ts @@ -92,7 +92,10 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session callId, content: [{ type: 'text', text: 'Tool call interrupted by a crash; no result was recorded.' }], isError: true, - error: { name: 'InterruptedError', code: 'interrupted' }, + error: { + message: 'Tool call interrupted by a crash; no result was recorded.', + info: { name: 'InterruptedError', code: 'interrupted' }, + }, }, surfaceOp: 'append', ...callSeq !== undefined ? { sourceEventSeqs: [callSeq] } : {}, diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index ea0ad55a51..cc7ea81ba1 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -243,15 +243,24 @@ export interface SessionEventMap { */ 'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string } /** - * A completed tool call's model-facing result, plus an optional tool-private - * `meta` presentation payload. `meta` is opaque to the core (`unknown` — the - * producing tool owns its shape and reads it back in `presentResult`) but MUST - * be JSON-serializable: `Session.append` runtime-validates all event data with - * `isJsonValue`, so a non-serializable `meta` is rejected at the source, and the - * durable log reproduces the identical card on replay. Absent unless the tool - * attaches one (e.g. `dsh-tool-fs` carries its result-time contextual diff here). + * A completed tool call's model-facing result, canonical failure detail, and + * optional tool-private `meta` presentation payload. `meta` is opaque to the + * core (the producing tool owns its shape and reads it back in `presentResult`) + * but MUST be JSON-serializable: `Session.append` runtime-validates all event + * data with `isJsonValue`, so a non-serializable `meta` is rejected at the + * source, and the durable log reproduces the identical card on replay. Absent + * unless the tool attaches one (e.g. `dsh-tool-fs` carries its result-time + * contextual diff here). */ - 'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown } + 'tool/result': { + turn: number + step: number + callId: CallId + content: ContentBlock[] + isError: boolean + error?: { message: string; info?: { name: string; code: string } } + meta?: JsonValue + } /** Steering content injected between steps of a running turn. */ 'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource } /** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */ diff --git a/packages/core/session/tests/repair.spec.ts b/packages/core/session/tests/repair.spec.ts index 765502b8ce..826d410dc8 100644 --- a/packages/core/session/tests/repair.spec.ts +++ b/packages/core/session/tests/repair.spec.ts @@ -64,7 +64,7 @@ describe('interruptedTurnClosers', () => { expect(closers.map(e => e.seq)).toEqual([3, 4, 5]) const result = closers[0]! expect(result.type === 'tool/result' && result.data).toMatchObject({ - turn: 2, step: 1, callId: CallId('call-1'), isError: true, error: { code: 'interrupted' }, + turn: 2, step: 1, callId: CallId('call-1'), isError: true, error: { info: { code: 'interrupted' } }, }) }) diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 5ec2d4e7e4..1b42f2c28f 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -15,7 +15,7 @@ tools: ### Public API -- `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. `timeoutMs`, when present, must be positive and finite. Disposed with the calling fiber. +- `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition with a mandatory canonical `output` declaration. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Missing/unsupported output declarations and a non-positive/non-finite `timeoutMs` fail at registration. Disposed with the calling fiber. - `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals). - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md)). @@ -33,14 +33,14 @@ The live registry pipeline has three transformable waterfalls followed by the ob ### Key types -- `ToolDefinition` — `ToolSchema` + `execute(args, exec)`, optional presentation callbacks, cooperative `timeoutMs`, and optional per-call `isConcurrencySafe(args)` classification. +- `ToolDefinition` — `ToolSchema` + mandatory `output { schema, render, presentationMeta? }` + `execute(args, exec)`, optional presentation callbacks, cooperative `timeoutMs`, and optional per-call `isConcurrencySafe(args)` classification. A body returns only the canonical JSON value declared by the output schema. - `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, agent?, parent?, signal? }`; callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token. - `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary. - `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore. A nested call's `parent` is a `ToolExecutionToken`, not an execution object. - `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws; it never injects immediately. -- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ content, isError, error?, additionalContexts?, meta? }`. Call identity stays on the immutable `ToolExecution` supplied alongside the result instead of being duplicated on the outcome. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text. `additionalContexts` preserves each deferred or post-execute `HookContext` with its own source and durable JSON metadata; the loop buffers the array and appends each entry as a `context/message` after all `tool/result`s in the step. +- `ToolExecutionResult` — discriminated execution-local outcome. Success is `{ isError:false, value:JsonValue, content, meta?, additionalContexts? }`; failure is `{ isError:true, error:{ message, info? }, content, meta?, additionalContexts? }` and has no value. Call identity stays on the immutable `ToolExecution`. The registry snapshots, validates, and freezes the canonical value before rendering, then materializes the durable presentation fields before final observation. `ToolFailure.info` carries an internal `{ name, code }` for a `HarnessError`; `additionalContexts` preserves every deferred or post-execute `HookContext` for the loop's post-result FIFO. - `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny. -- `PostToolDecision` — `{kind:'accept', content?, additionalContexts?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContexts?}` (turn it into an `isError` whose content is the corrective feedback). Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision. +- `PostToolDecision` — accept may replace `content` or `value`, never both, and may attach `additionalContexts`; block turns feedback into a valueless failure. Content replacement preserves the canonical value and metadata. Value replacement is revalidated and rerenders content/metadata. Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision. - `ToolGuard` — `(execution) => string | undefined`; the returned string is a final monotonic denial reason evaluated after the reorderable pre-execute waterfall and before dispatch. - `ToolCallView` / `ToolResultView` — provider-neutral `card`-tagged render intents a tool returns from `presentCall` / `presentResult` to own how a UI renders ITS calls (see "Tool-owned UI presentation"). @@ -48,8 +48,8 @@ The live registry pipeline has three transformable waterfalls followed by the ob - Tool plugins call `ctx.tools.register()` — schemas flow into the assembly automatically. - `tools/pre-execute` is the reorderable allow/deny/ask gate; `ctx.tools.guard()` adds monotonic owner policy after it. -- `tools/execute` wraps normalized core dispatch for timeout, retry, or metrics. Wrappers may replace only the operational signal. -- `tools/post-execute` may replace content, block with feedback, or attach ordered contexts; `tools/result` observes the immutable final outcome. +- `tools/execute` wraps normalized canonical dispatch for timeout, retry, or metrics. Wrappers may replace only the operational signal; a wrapper-authored success is normalized through the resolved tool's output declaration. +- `tools/post-execute` may replace presentation content, replace the canonical value, block with feedback, or attach ordered contexts; `tools/result` observes the immutable final outcome. Content replacement is not a confidentiality boundary: block or replace the value when programmatic consumers must not receive it. - Exact signatures and ordering live in the generated [event catalog](../../../docs/cordis-catalog/events.md) and [pipeline](../../../docs/tool-execution-pipeline.md). - MCP servers: one plugin per server, discover tools, call `ctx.tools.register()` with the server's schemas. @@ -72,17 +72,20 @@ ctx.tools.register(defineTool({ offset: { type: 'number' }, limit: { type: 'number' }, }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, async execute(args, exec) { // args is typed: { path: string; offset?: number; limit?: number } - const text = await readFile(args.path, 'utf8') - return [{ type: 'text', text }] + return readFile(args.path, 'utf8') }, })) ``` 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. It also infers the body return and pure output projectors from `output.schema`; the registry snapshots and validates the returned lossless JSON before presentation. Extra parameter 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 input validation but still declare and receive registry-enforced output. See `defineTool`, `validateArgs`, `ToolArgsError`, `ValueSchemaSpec`, `ParameterSchemaSpec`, `InferValue`, `InferArgs`, `valueSchemaSpecToJsonSchema`, and `parameterSchemaSpecToJsonSchema` in the public API for details. @@ -101,7 +104,7 @@ Tools optionally own pure `presentCall()` and `presentResult()` render intents, - Call views are `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`, `{ card: 'terminal', title, description?, cwd? }`, or `{ card: 'diff', title, diffs, locations? }`. - Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, or `{ card: 'diff', title?, diffs }`. -Returning `undefined` selects generic fallback. Presenters depend only on their arguments because UIs call them during live streaming and log replay. Result presentation may read JSON-serializable `result.meta`, which persists with the result; `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns the rationale. +Returning `undefined` selects generic fallback. Presenters depend only on their arguments and the durable result because UIs call them during live streaming and log replay. `output.presentationMeta(args, value)` derives JSON metadata for direct surface calls; that metadata persists with `tool/result` and returns to `presentResult`, while the canonical value itself remains execution-local and is never replayed. Nested Code dispatches do not compute metadata. `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [canonical-output Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/presentation split and the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns card vocabulary. ### Code Mode diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 0a65c9e434..21fcfcb8b5 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -10,7 +10,7 @@ import { inspect } from 'node:util' import { CallId, HarnessError } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { CodeBindingFunction, CodeRunResult, CodeRuntime } from '@deepseek-ai/dsh-code-runtime' -import type {} from '@deepseek-ai/dsh-session' +import type { JsonValue } from '@deepseek-ai/dsh-session' import { defineTool } from './schema.ts' import type { ToolDefinition, ToolRegistry } from './index.ts' @@ -111,9 +111,8 @@ function jsonNormalizeArgs(value: unknown): { dispatched: unknown; logged: unkno return { dispatched: JSON.parse(text) as unknown, logged: JSON.parse(text) as unknown } } -/** Render the program's completion value for the model-facing result text (`''` when the program returned nothing). */ -function renderValue(value: unknown): string { - if (value === undefined) return '' +/** Render one present program completion value for the model-facing result text. */ +function renderValue(value: JsonValue): string { return typeof value === 'string' ? value : inspect(value, INSPECT_OPTIONS) } @@ -122,6 +121,9 @@ interface RunCodeMeta { logs: CodeRunResult['logs'] } +/** Canonical value returned by the outer Code Mode transport. */ +type RunCodeOutput = { logs: string[]; result?: JsonValue } + /** Soft-narrow a result `meta` back to {@link RunCodeMeta} (replay may carry older shapes; presentation must not throw). */ function asRunCodeMeta(meta: unknown): RunCodeMeta | undefined { if (typeof meta !== 'object' || meta === null) return undefined @@ -152,7 +154,23 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => parameters: { code: { type: 'string', required: true, description: 'The program: the body of an async TypeScript function.' }, }, - async execute(args, exec) { + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + logs: { type: 'array', required: true, items: { type: 'string' } }, + result: { type: 'json' }, + }, + }, + render: (_args, value) => { + const rendered = value.result === undefined ? '' : renderValue(value.result) + const parts = [value.logs.join('\n'), rendered].filter(part => part.length > 0) + return [{ type: 'text', text: parts.length > 0 ? parts.join('\n') : '(run_code completed with no output)' }] + }, + presentationMeta: (_args, value) => ({ logs: value.logs }), + }, + async execute(args, exec): Promise { const runtime = requireRuntime() // The run-scoped abort: follows the outer signal in, and fires when the @@ -265,12 +283,12 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => const logsText = result.logs.length > 0 ? `\nCaptured output:\n${result.logs.join('\n')}` : '' throw new CodeRunFailedError(`code run failed (${result.error.kind}): ${result.error.message}${logsText}`) } - const rendered = renderValue(result.value) - const parts = [result.logs.join('\n'), rendered].filter(part => part.length > 0) - const meta: RunCodeMeta = { logs: result.logs } + // The runtime seam is wider than JSON until PR 3 makes this boundary + // lossless. The registry immediately snapshots and rejects any value + // that does not satisfy the declared JSON output. return { - content: [{ type: 'text', text: parts.length > 0 ? parts.join('\n') : '(run_code completed with no output)' }], - meta, + logs: result.logs, + ...result.value !== undefined ? { result: result.value as JsonValue } : {}, } } finally { exec.signal?.removeEventListener('abort', onOuterAbort) diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 2c01639047..f6fe38d4da 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -12,12 +12,15 @@ import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' import { assertNever, deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm' import type { Agent, HookContext } from '@deepseek-ai/dsh-agent' import { snapshotJsonValue } from '@deepseek-ai/dsh-session' +import type { JsonValue } from '@deepseek-ai/dsh-session' import type { ToolProviderResult } from '@deepseek-ai/dsh-system-prompt' import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' // Type-only: makes `ctx.get('approval')` resolve to the ApprovalService // augmentation. The seam stays optional at runtime — see `serviceAsk`. import type {} from '@deepseek-ai/dsh-user-approval' import type { ToolCallView, ToolResultView } from './presentation.ts' +import { assertSupportedJsonSchema, validateJsonSchemaValue } from './json-schema.ts' +import type { JsonSchemaNode } from './json-schema.ts' import { createRunCodeTool, RUN_CODE_NAME, SDK_SECTION_ORDER } from './code-mode.ts' import { renderToolsSdk } from './ts-types.ts' @@ -61,6 +64,7 @@ export type { JsonValue } from '@deepseek-ai/dsh-session' export { CodeRunFailedError, RUN_CODE_NAME } from './code-mode.ts' export { jsonSchemaToTs, renderToolsSdk } from './ts-types.ts' +export { defineContentToolFixture, type ContentToolFixtureOptions } from './testing.ts' // The render-intent vocabulary a tool declares via `presentCall`/`presentResult` // lives in its own UI-facing module; re-export it so `@deepseek-ai/dsh-tools` @@ -132,12 +136,22 @@ declare module 'cordis' { } } -/** Tool output, optionally with lossless-JSON presentation metadata persisted for replay. */ -export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown } +/** Tool-owned canonical output contract used after the body returns a JSON value. */ +export interface ToolOutputDefinition { + /** Raw supported JSON Schema enforced against every successful canonical value. */ + readonly schema: JsonSchemaNode + /** Pure projection from validated arguments and value to Native/model content. */ + render(args: unknown, value: JsonValue): ContentBlock[] + /** Pure replayable presentation projection, computed only for surface calls. */ + presentationMeta?(args: unknown, value: JsonValue): JsonValue +} /** A registered tool: its schema plus the execution function. */ export interface ToolDefinition extends ToolSchema { - execute(args: unknown, exec: ToolRunContext): Promise + /** Mandatory canonical output declaration. */ + readonly output: ToolOutputDefinition + /** Execute the tool and return only its canonical lossless-JSON value. */ + execute(args: unknown, exec: ToolRunContext): Promise /** * Cooperative tool-call timeout budget in milliseconds. Omit for no deadline. * Enforced by `@deepseek-ai/dsh-timeout-policy` (a `tools/execute` wrapper); it @@ -172,7 +186,7 @@ export interface ToolDefinition extends ToolSchema { presentCall?(args: unknown): ToolCallView | undefined /** * Optional: how to present the COMPLETED state, given the same `args` and the - * `result` (`execute`'s content + whether it errored). Returns a + * durable result projection (`content`, failure state, and optional `meta`). Returns a * {@link ToolResultView}, or `undefined` (or omit the method) to keep the * pending title and render the raw result content. Pure and side-effect-free * for the same replay reason. @@ -182,17 +196,16 @@ export interface ToolDefinition extends ToolSchema { /** The completed outcome handed to {@link ToolDefinition.presentResult}. */ export interface ToolResult { - /** The model-facing content `execute` returned (or the error text on failure). */ + /** The final model-facing content (or the rendered error text on failure). */ content: ContentBlock[] /** Whether the call failed. */ isError: boolean /** - * The tool-private presentation payload the tool attached from `execute` (via - * the object return form), threaded verbatim from the `tool/result` event. - * Opaque (`unknown`); the tool narrows it back to its own shape. Absent when - * the tool attached none. + * The tool-private presentation payload projected by its output declaration + * and threaded verbatim from the `tool/result` event. Absent when the tool + * declared no projector or the call was nested under a composite transport. */ - meta?: unknown + meta?: JsonValue } declare const toolExecutionTokenBrand: unique symbol @@ -303,6 +316,14 @@ export interface ToolErrorInfo { code: string } +/** Canonical failure detail; internal routing information remains optional. */ +export interface ToolFailure { + /** Human-readable failure message without the Native `Error: ` envelope. */ + message: string + /** Internal error class/code used by policy and durable diagnostics. */ + info?: ToolErrorInfo +} + /** * Thrown (internally) when the model requests a tool that isn't registered. * Extends {@link HarnessError} (`code: 'UNKNOWN_TOOL'`) so an unknown-tool @@ -316,30 +337,42 @@ export class ToolNotFoundError extends HarnessError { } } -/** The outcome of one tool call. */ -export interface ToolExecutionResult { - content: ContentBlock[] - isError: boolean - /** - * Set when the call failed with a {@link HarnessError}: machine-routable - * `{ name, code }` for retry/sandbox plugins and replay. The model-facing - * text in `content` is always present; this is extra structure for code. - */ - error?: ToolErrorInfo - /** - * Model-facing context for the next request, separate from this tool result. The loop - * accepts it into the active-batch FIFO, then appends after recorded results even if interrupted. - */ - additionalContexts?: HookContext[] - /** - * The tool-private presentation payload from a successful `execute` (the object - * return form). Threaded onto the `tool/result` session event and back into - * {@link ToolResult} for `presentResult`. Opaque (`unknown`); absent when the - * tool attached none or the call failed. - */ - meta?: unknown +/** Thrown when a tool body or post-policy value violates its declared output. */ +export class ToolOutputError extends HarnessError { + /** Schema/value violations in validation order. */ + readonly violations: string[] + + constructor(toolName: string, violations: string[]) { + super(`tool "${toolName}" returned invalid output: ${violations.join('; ')}`, 'INVALID_TOOL_OUTPUT') + this.name = 'ToolOutputError' + this.violations = violations + } } +/** Successful canonical tool execution, including its Native/model projection. */ +export interface ToolExecutionSuccess { + readonly isError: false + /** Execution-local canonical value; deliberately omitted from durable events. */ + readonly value: JsonValue + readonly content: ContentBlock[] + readonly error?: never + readonly meta?: JsonValue + readonly additionalContexts?: HookContext[] +} + +/** Failed canonical tool execution; failures never carry a successful value. */ +export interface ToolExecutionFailure { + readonly isError: true + readonly error: ToolFailure + readonly value?: never + readonly content: ContentBlock[] + readonly meta?: JsonValue + readonly additionalContexts?: HookContext[] +} + +/** The discriminated, execution-local outcome of one tool call. */ +export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure + /** * Pre-dispatch decision. `allow` runs the call; `deny` materializes an error; * `ask` runs only after an approval service returns `allowed-once` and otherwise @@ -352,11 +385,12 @@ export type PreToolDecision = | { kind: 'ask'; reason?: string } /** - * Post-dispatch decision: accept or replace content, attach context for the next - * request, or block by turning corrective feedback into an error result. + * Post-dispatch decision: accept, replace one projection, attach context for the + * next request, or block by turning corrective feedback into an error result. */ export type PostToolDecision = - | { kind: 'accept'; content?: ContentBlock[]; additionalContexts?: HookContext[] } + | { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: HookContext[] } + | { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: HookContext[] } | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: HookContext[] } /** @@ -381,6 +415,23 @@ function errorMessage(error: unknown): string { } } +/** Derive one failure message from policy feedback without changing its rendered blocks. */ +function failureMessageFromContent(content: ContentBlock[]): string { + const text = content + .map(block => block.type === 'text' ? block.text : `[${block.type} content]`) + .join('\n') + return text.length > 0 ? text : 'tool result blocked by post-execute policy' +} + +/** Snapshot and freeze one durable tool-result projection or reject lossy data. */ +function materializePresentation(candidate: T): T { + const detached = snapshotJsonValue(candidate) + if (detached === undefined) { + throw new TypeError('tool result must be losslessly JSON-serializable') + } + return deepFreeze(detached) +} + /** Structured `{ name, code }` for a thrown HarnessError, else undefined. */ function errorInfo(error: unknown): ToolErrorInfo | undefined { try { @@ -553,6 +604,13 @@ export class ToolRegistry extends Service { register(definition: ToolDefinition): () => void { const scope = scopeOf(this.ctx) const name = definition.name + const output = (definition as Partial).output + if (output === undefined || typeof output !== 'object' + || typeof output.render !== 'function' + || (output.presentationMeta !== undefined && typeof output.presentationMeta !== 'function')) { + throw new TypeError(`tool "${name}" must declare output { schema, render, presentationMeta? }`) + } + assertSupportedJsonSchema(output.schema) const timeoutMs = definition.timeoutMs if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) { @@ -880,10 +938,11 @@ export class ToolRegistry extends Service { return await next({ kind: 'post-result', exec, - result: { + result: this.materializeFinalResult({ content: [{ type: 'text', text: `Error: ${denialReason}` }], isError: true, - }, + error: { message: denialReason }, + }), }) } return await next({ kind: 'dispatch', exec }) @@ -909,27 +968,26 @@ export class ToolRegistry extends Service { const tool = this.get(exec.name, exec.agent) if (!tool) throw new ToolNotFoundError(exec.name) const returned = await tool.execute(exec.arguments, exec) - const content = Array.isArray(returned) ? returned : returned.content - const meta = Array.isArray(returned) ? undefined : returned.meta - return { content, isError: false, ...meta !== undefined ? { meta } : {} } + return this.createSuccessResult(exec, tool, returned) } catch (error: unknown) { - return toolErrorResult(error) + return this.materializeFinalResult(toolErrorResult(error)) } }, ) + const normalized = this.normalizeDispatchResult(exec, result) const deferredContexts = this.deferredContexts.get(exec) /* v8 ignore next -- dispatch only receives executions minted by this registry's prepare stage */ if (deferredContexts === undefined) throw new Error('tool registry scheduler invariant violated: unprepared execution') const resultWithDeferredContexts: ToolExecutionResult = deferredContexts.length === 0 - ? result - : { - ...result, + ? normalized + : this.markCanonical({ + ...normalized, additionalContexts: [ ...deferredContexts, - ...result.additionalContexts ?? [], + ...normalized.additionalContexts ?? [], ], - } - return { kind: 'post-result', result: resultWithDeferredContexts } + }) + return { kind: 'post-result', result: this.materializeFinalResult(resultWithDeferredContexts) } } catch (error: unknown) { return { kind: 'final-result', result: toolErrorResult(error) } } @@ -1046,32 +1104,103 @@ export class ToolRegistry extends Service { ) const decisionContexts = decision.additionalContexts ?? [] if (decision.kind === 'block') { - return { + const message = failureMessageFromContent(decision.feedback) + return this.markCanonical({ content: decision.feedback, isError: true, + error: { message }, ...decisionContexts.length > 0 ? { additionalContexts: decisionContexts } : {}, - } + }) + } + if (Object.hasOwn(decision, 'content') && Object.hasOwn(decision, 'value')) { + throw new TypeError('tools/post-execute accept decision cannot replace both value and content') } - // Accept: replace content if supplied, preserve the dispatched outcome, and - // append decision contexts after contexts deferred by the tool body. const additionalContexts = [ ...result.additionalContexts ?? [], ...decisionContexts, ] - return { - ...result, - ...decision.content ? { content: decision.content } : {}, - ...additionalContexts.length > 0 ? { additionalContexts } : {}, + if (Object.hasOwn(decision, 'value')) { + if (result.isError) { + throw new TypeError('tools/post-execute cannot replace the value of a failed result') + } + const tool = this.get(exec.name, exec.agent) + if (tool === undefined) throw new ToolNotFoundError(exec.name) + const replaced = this.createSuccessResult(exec, tool, decision.value) + return this.markCanonical({ + ...replaced, + ...additionalContexts.length > 0 ? { additionalContexts } : {}, + }) } + return this.markCanonical({ + ...result, + ...decision.content !== undefined ? { content: decision.content } : {}, + ...additionalContexts.length > 0 ? { additionalContexts } : {}, + }) + } + + /** Results created by the registry already own a validated, frozen canonical value. */ + private readonly canonicalResults = new WeakSet() + + /** Mark a registry-normalized result without freezing presentation fields prematurely. */ + private markCanonical(result: T): T { + this.canonicalResults.add(result) + return result + } + + /** Snapshot, validate, render, and optionally project one successful body value. */ + private createSuccessResult(exec: ToolExecution, tool: ToolDefinition, candidate: unknown): ToolExecutionSuccess { + const detached = snapshotJsonValue(candidate) + if (detached === undefined) { + throw new ToolOutputError(tool.name, ['value is not lossless JSON']) + } + const violations = validateJsonSchemaValue(tool.output.schema, detached, 'value') + if (violations.length > 0) throw new ToolOutputError(tool.name, violations) + const value = deepFreeze(detached as JsonValue) + const content = tool.output.render(exec.arguments, value) + const meta = exec.parent === undefined && tool.output.presentationMeta !== undefined + ? tool.output.presentationMeta(exec.arguments, value) + : undefined + return this.markCanonical(this.materializeFinalResult({ + isError: false, + value, + content, + ...meta !== undefined ? { meta } : {}, + }) as ToolExecutionSuccess) + } + + /** Normalize an around-dispatch wrapper's authored result through the owning output contract. */ + private normalizeDispatchResult(exec: ToolExecution, result: ToolExecutionResult): ToolExecutionResult { + if (this.canonicalResults.has(result)) return result + if (result.isError) { + return this.markCanonical({ + isError: true, + error: result.error, + content: result.content, + ...result.meta !== undefined ? { meta: result.meta } : {}, + ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {}, + }) + } + const tool = this.get(exec.name, exec.agent) + if (tool === undefined) throw new ToolNotFoundError(exec.name) + const normalized = this.createSuccessResult(exec, tool, result.value) + return this.markCanonical({ + ...normalized, + ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {}, + }) } /** Materialize the authoritative commit outcome once, immediately before `tools/result`. */ private materializeFinalResult(result: ToolExecutionResult): ToolExecutionResult { - const detached = snapshotJsonValue(result) - if (detached === undefined) { - throw new TypeError('tool result must be losslessly JSON-serializable') + const presentation = { + content: result.content, + ...result.meta !== undefined ? { meta: result.meta } : {}, + ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {}, } - return deepFreeze(detached) + if (result.isError) { + return materializePresentation({ isError: true as const, error: result.error, ...presentation }) + } + const detached = materializePresentation({ isError: false as const, ...presentation }) + return deepFreeze({ ...detached, value: result.value }) } } @@ -1082,10 +1211,11 @@ function createExecutionToken(): ToolExecutionToken { function toolErrorResult(error: unknown): ToolExecutionResult { const info = errorInfo(error) + const message = errorMessage(error) return { - content: [{ type: 'text', text: `Error: ${errorMessage(error)}` }], + content: [{ type: 'text', text: `Error: ${message}` }], isError: true, - ...info ? { error: info } : {}, + error: { message, ...info ? { info } : {} }, } } diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts index 98be959e4e..d81a97508c 100644 --- a/packages/core/tools/src/schema.ts +++ b/packages/core/tools/src/schema.ts @@ -1,8 +1,9 @@ /** Unified JSON-value schema DSL, inference, compilation, and typed tool helper. @module dsh-tools/schema */ import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { JsonValue } from '@deepseek-ai/dsh-session' -import type { ToolDefinition, ToolExecuteReturn, ToolRunContext, ToolResult } from './index.ts' +import type { ToolDefinition, 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' @@ -114,21 +115,25 @@ type RequiredKeys = { [K in keyof S]: S[K] extends { required: true } ? K : never }[keyof S] +/** Advance the bounded inference walk through one nested schema node. */ +type NextDepth = readonly [...D, unknown] + /** Infer the declared value of one parameter property without key optionality. */ -type InferProperty

= P extends ValueSchemaSpec ? InferValue

: never +type InferProperty

= + P extends ValueSchemaSpec ? InferValue : never /** Infer an implicit property map into required and optional object keys. */ -type InferProperties = Simplify< - & { [K in RequiredKeys]: InferProperty } - & { [K in Exclude>]?: InferProperty } +type InferProperties = Simplify< + & { [K in RequiredKeys]: InferProperty } + & { [K in Exclude>]?: InferProperty } > /** Infer an explicit object node, including its declared openness. */ -type InferObject = +type InferObject = S extends { properties: infer P extends ParameterSchemaSpec } ? S['additionalProperties'] extends true - ? InferProperties

& Record - : InferProperties

+ ? InferProperties & Record + : InferProperties : S['additionalProperties'] extends true ? Record : Record @@ -143,20 +148,21 @@ type InferScalar = * Infer the TypeScript value accepted by an author-facing value schema. * Output schemas may therefore infer object, array, scalar, or null roots. */ -export type InferValue = - S extends StringValueSchemaSpec ? InferScalar : - S extends NumberValueSchemaSpec | IntegerValueSchemaSpec ? InferScalar : - S extends BooleanValueSchemaSpec ? InferScalar : - S extends NullValueSchemaSpec ? null : - S extends ArrayValueSchemaSpec - ? S extends { items: infer I extends ValueSchemaSpec } ? InferValue[] : JsonValue[] - : S extends ObjectValueSchemaSpec ? InferObject : - S extends JsonValueSchemaSpec ? JsonValue : - S extends OneOfValueSchemaSpec ? InferValue : - never +export type InferValue = + D['length'] extends 12 ? JsonValue : + S extends StringValueSchemaSpec ? InferScalar : + S extends NumberValueSchemaSpec | IntegerValueSchemaSpec ? InferScalar : + S extends BooleanValueSchemaSpec ? InferScalar : + S extends NullValueSchemaSpec ? null : + S extends ArrayValueSchemaSpec + ? S extends { items: infer I extends ValueSchemaSpec } ? InferValue>[] : JsonValue[] + : S extends ObjectValueSchemaSpec ? InferObject> : + S extends JsonValueSchemaSpec ? JsonValue : + S extends OneOfValueSchemaSpec ? InferValue> : + never /** Infer the TypeScript argument object for an implicit parameter schema. */ -export type InferArgs = InferProperties +export type InferArgs = InferProperties const ANNOTATION_KEYS = ['description', 'title', 'default', 'examples'] as const @@ -329,13 +335,22 @@ export function validateArgs(spec: ParameterSchemaSpec, args: unknown): string[] } /** Options for {@link defineTool}. */ -export interface DefineToolOptions { +export interface DefineToolOptions { /** Tool name (must be unique). */ readonly name: string /** Human-readable description sent to the model. */ readonly description: string /** Per-property parameter schema compiled to an implicit open object root. */ readonly parameters: S + /** Canonical output schema plus pure Native and presentation projections. */ + readonly output: { + /** Schema enforced against every successful body or policy-replaced value. */ + readonly schema: O + /** Pure Native/model rendering of one validated canonical value. */ + render(args: InferArgs, value: InferValue>): ContentBlock[] + /** Pure replayable presentation metadata for direct surface calls. */ + presentationMeta?(args: InferArgs, value: InferValue>): JsonValue + } /** Optional positive cooperative timeout budget in milliseconds. */ readonly timeoutMs?: number /** @@ -348,9 +363,9 @@ export interface DefineToolOptions { * Execute the tool after argument validation. * @param args - typed validated arguments. * @param exec - execution identity, caller, cancellation, and nesting data. - * @returns Model-facing content and optional presentation metadata. + * @returns The canonical value declared by `output.schema`. */ - execute(args: InferArgs, exec: ToolRunContext): Promise + execute(args: InferArgs, exec: ToolRunContext): Promise>> /** * Pure pending-state presenter. * @param args - typed validated arguments. @@ -373,11 +388,17 @@ export interface DefineToolOptions { * @param options - typed definition and optional presenters. * @returns A registry-ready definition. */ -export function defineTool(options: DefineToolOptions): ToolDefinition { +export function defineTool( + options: DefineToolOptions, +): ToolDefinition { // Object-literal methods do not use `this`; retaining references is safe. // eslint-disable-next-line @typescript-eslint/unbound-method const userExecute = options.execute // eslint-disable-next-line @typescript-eslint/unbound-method + const userRender = options.output.render + // eslint-disable-next-line @typescript-eslint/unbound-method + const userPresentationMeta = options.output.presentationMeta + // eslint-disable-next-line @typescript-eslint/unbound-method const userPresentCall = options.presentCall // eslint-disable-next-line @typescript-eslint/unbound-method const userPresentResult = options.presentResult @@ -387,16 +408,28 @@ export function defineTool(options: DefineToolOpt throw new Error(`defineTool(${options.name}): timeoutMs must be a positive finite number`) } const parameters = parameterSchemaSpecToJsonSchema(options.parameters) + const outputSchema = valueSchemaSpecToJsonSchema(options.output.schema) const validate = (args: unknown): string[] => validateJsonSchemaValue(parameters, args, '') const tool: ToolDefinition = { name: options.name, description: options.description, parameters: parameters as unknown as Record, + output: { + schema: outputSchema, + render(args: unknown, value: JsonValue): ContentBlock[] { + return userRender(args as InferArgs, value as unknown as InferValue>) + }, + ...userPresentationMeta !== undefined ? { + presentationMeta(args: unknown, value: JsonValue): JsonValue { + return userPresentationMeta(args as InferArgs, value as unknown as InferValue>) + }, + } : {}, + }, ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), - async execute(args: unknown, exec: ToolRunContext): Promise { + async execute(args: unknown, exec: ToolRunContext): Promise { const violations = validate(args) if (violations.length > 0) throw new ToolArgsError(violations) - return userExecute(args as InferArgs, exec) + return userExecute(args as InferArgs, exec) as Promise }, } if (userPresentCall) { diff --git a/packages/core/tools/src/testing.ts b/packages/core/tools/src/testing.ts new file mode 100644 index 0000000000..ad9fa52d85 --- /dev/null +++ b/packages/core/tools/src/testing.ts @@ -0,0 +1,42 @@ +/** Canonical tool-definition fixtures for repository tests. @module dsh-tools/testing */ + +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { JsonValue } from '@deepseek-ai/dsh-session' +import { defineTool } from './schema.ts' +import type { DefineToolOptions, ParameterSchemaSpec } from './schema.ts' +import type { ToolDefinition, ToolRunContext } from './index.ts' + +const CONTENT_VALUE_SCHEMA = { type: 'array', items: { type: 'json' } } as const + +/** Options for a fixture whose canonical value is its rendered content array. */ +export type ContentToolFixtureOptions = Omit< + DefineToolOptions, + 'output' | 'execute' +> & { + /** Produce the fixture's content blocks as its canonical test value. */ + execute(args: import('./schema.ts').InferArgs, exec: ToolRunContext): Promise +} + +/** + * Define a test fixture that deliberately uses its content blocks as the + * canonical JSON value. Product tools must declare domain-owned DTOs instead. + * @param options - ordinary fixture fields plus a content-producing body. + * @returns a registry-ready tool with an explicit JSON-array output contract. + * @internal + */ +export function defineContentToolFixture( + options: ContentToolFixtureOptions, +): ToolDefinition { + // eslint-disable-next-line @typescript-eslint/unbound-method + const execute = options.execute + return defineTool({ + ...options, + output: { + schema: CONTENT_VALUE_SCHEMA, + render: (_args, value) => value as unknown as ContentBlock[], + }, + async execute(args, exec) { + return await execute(args, exec) as unknown as JsonValue[] + }, + }) +} diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 227ff98129..0fe48af14a 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -6,7 +6,7 @@ import type { Scope } from '@deepseek-ai/dsh-scope' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' -import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, defineContentToolFixture } from '@deepseek-ai/dsh-tools' import type { Config, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' import { Session, SessionId } from '@deepseek-ai/dsh-session' @@ -68,7 +68,7 @@ async function mintAgentScope(ctx: Context, name = 'scoped'): Promise<{ scope: S /** Register a trivial echo tool; returns the calls it received. */ function registerEcho(ctx: Context, name = 'echo'): unknown[] { const calls: unknown[] = [] - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name, description: `Echo tool ${name}.`, parameters: { value: { type: 'string', required: true } }, @@ -217,7 +217,7 @@ describe('mode-aware wire contribution', () => { it.each(['code', 'both'] as const)('reserves run_code against scoped shadows and explicit restrictions in mode %s', async (mode) => { const { ctx, systemPrompt } = await setup({ mode }) const { scope, agent } = await mintAgentScope(ctx) - const impostor = defineTool({ + const impostor = defineContentToolFixture({ name: RUN_CODE_NAME, description: 'Scoped impostor.', parameters: {}, @@ -229,7 +229,7 @@ describe('mode-aware wire contribution', () => { expect(() => scope.ctx.tools.restrict({ allow: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/) expect(() => scope.ctx.tools.restrict({ deny: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/) scope.ctx.systemPrompt.section({ name: 'scoped-note', order: 149, text: 'safe note' }) - scope.ctx.tools.register(defineTool({ + scope.ctx.tools.register(defineContentToolFixture({ name: 'scoped_safe', description: 'Safe scoped tool.', parameters: {}, @@ -332,6 +332,8 @@ describe('the run_code dispatch bridge', () => { } const result = await runCode(ctx, 'const …: string = …', { agent }) expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected run_code success') + expect(result.value).toEqual({ logs: ['saw echo:one'], result: 'echo:two' }) expect(result.content).toEqual([{ type: 'text', text: 'saw echo:one\necho:two' }]) expect(calls).toEqual([{ value: 'one' }, { value: 'two' }]) const dispatches = events.filter(event => event.type === 'tool/code-dispatch') @@ -374,7 +376,7 @@ describe('the run_code dispatch bridge', () => { const { ctx, runtime } = await setup({ mode: 'code' }) const intervals: [string, string][] = [] let active = 0 - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'probe', description: 'Records execution overlap.', parameters: { id: { type: 'string', required: true } }, @@ -405,7 +407,7 @@ describe('the run_code dispatch bridge', () => { it('rejects the program-side call when the tool errors, with the tool error text', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'fail', description: 'Always fails.', parameters: {}, @@ -549,7 +551,7 @@ describe('the run_code dispatch bridge', () => { }) const result = await runCode(ctx, 'program') expect(result.isError).toBe(true) - expect(result.error).toEqual({ name: 'CodeRunFailedError', code: 'CODE_RUN_FAILED' }) + expect(result.error).toMatchObject({ info: { name: 'CodeRunFailedError', code: 'CODE_RUN_FAILED' } }) const text = (result.content[0] as { text: string }).text expect(text).toContain('code run failed (timeout)') expect(text).toContain('compute budget exhausted') @@ -566,7 +568,7 @@ describe('the run_code dispatch bridge', () => { const { ctx, runtime } = await setup({ mode: 'code' }) const seen: string[] = [] let sawAbort = false - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'slow', description: 'Slow tool observing its signal.', parameters: { id: { type: 'string', required: true } }, @@ -602,7 +604,7 @@ describe('the run_code dispatch bridge', () => { let sawAbort = false let started!: () => void const inFlight = new Promise((resolve) => { started = resolve }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'slow', description: 'Slow tool observing its signal.', parameters: { id: { type: 'string', required: true } }, @@ -689,7 +691,7 @@ describe('the run_code dispatch bridge', () => { const { ctx, runtime } = await setup({ mode: 'code' }) const { agent, events } = fakeAgent() const long = 'x'.repeat(300) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'mixed', description: 'Returns mixed content.', parameters: {}, @@ -714,7 +716,7 @@ describe('the run_code dispatch bridge', () => { it('normalizes the session workspace root before bounding durable result summaries', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'workspace_path', description: 'Return a path beneath the session workspace.', parameters: {}, @@ -792,7 +794,7 @@ describe('the run_code dispatch bridge', () => { const { ctx, runtime } = await setup({ mode: 'code' }) const { agent, events } = fakeAgent() let mutationSucceeded: boolean | undefined - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'mutator', description: 'Attempts to mutate its args object.', parameters: { list: { type: 'array', required: true } }, @@ -814,7 +816,7 @@ describe('the run_code dispatch bridge', () => { it('exposes a tool named __proto__ as an ordinary own binding', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: '__proto__', description: 'A prototype-colliding tool name.', parameters: {}, diff --git a/packages/core/tools/tests/execution-mode.spec.ts b/packages/core/tools/tests/execution-mode.spec.ts index 9a12f33a51..ac490396a4 100644 --- a/packages/core/tools/tests/execution-mode.spec.ts +++ b/packages/core/tools/tests/execution-mode.spec.ts @@ -5,7 +5,7 @@ import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { - defineTool, + defineContentToolFixture, type ToolDefinition, type ToolExecutionInput, type ToolExecutionMode, @@ -25,7 +25,7 @@ function exec(name: string, args: unknown): ToolExecutionInput { describe('ToolRegistry.executionMode', () => { it('returns parallel only for an explicit true classifier', async () => { const ctx = await setup() - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'safe', description: 'parallel-safe', parameters: {}, @@ -37,7 +37,7 @@ describe('ToolRegistry.executionMode', () => { it('defaults to exclusive for a tool with no isConcurrencySafe declaration', async () => { const ctx = await setup() - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'plain', description: 'no declaration', parameters: {}, @@ -53,7 +53,7 @@ describe('ToolRegistry.executionMode', () => { it('returns exclusive when the classifier returns false for these args', async () => { const ctx = await setup() - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'rw', description: 'read or write', parameters: { mode: { type: 'string', required: true } }, @@ -64,9 +64,9 @@ describe('ToolRegistry.executionMode', () => { expect(ctx.tools.executionMode(exec('rw', { mode: 'write' }))).toEqual({ kind: 'exclusive' }) }) - it('classifies invalid defineTool arguments as exclusive without throwing', async () => { + it('classifies invalid defineContentToolFixture arguments as exclusive without throwing', async () => { const ctx = await setup() - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'needs-mode', description: 'requires mode', parameters: { mode: { type: 'string', required: true } }, @@ -82,8 +82,9 @@ describe('ToolRegistry.executionMode', () => { name: 'thrower', description: 'classifier throws', parameters: { type: 'object', properties: {} }, + output: { schema: { type: 'null' }, render: () => [] }, isConcurrencySafe() { throw new Error('boom') }, - async execute() { return [] }, + async execute() { return null }, } ctx.tools.register(raw) expect(ctx.tools.executionMode(exec('thrower', {}))).toEqual({ kind: 'exclusive' }) @@ -95,8 +96,9 @@ describe('ToolRegistry.executionMode', () => { name: 'truthy', description: 'classifier returns a truthy string', parameters: { type: 'object', properties: {} }, + output: { schema: { type: 'null' }, render: () => [] }, isConcurrencySafe() { return 'yes' }, - async execute() { return [] }, + async execute() { return null }, } as unknown as ToolDefinition ctx.tools.register(raw) expect(ctx.tools.executionMode(exec('truthy', {}))).toEqual({ kind: 'exclusive' }) @@ -109,8 +111,9 @@ describe('ToolRegistry.executionMode', () => { name: 'raw-safe', description: 'raw', parameters: { type: 'object', properties: {} }, + output: { schema: { type: 'null' }, render: () => [] }, isConcurrencySafe(args) { seen = args; return true }, - async execute() { return [] }, + async execute() { return null }, }) expect(ctx.tools.executionMode(exec('raw-safe', { anything: 1 }))).toEqual({ kind: 'parallel' }) expect(seen).toEqual({ anything: 1 }) @@ -118,7 +121,7 @@ describe('ToolRegistry.executionMode', () => { it('isConcurrencySafe never reaches the model-facing schemas() projection', async () => { const ctx = await setup() - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'safe', description: 'parallel-safe', parameters: { x: { type: 'string', required: true } }, diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index 843aeb3837..a214f4f6bf 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -9,7 +9,6 @@ import type { PreToolDecision, ToolDefinition, ToolExecution, ToolExecutionInput import type { Agent } from '@deepseek-ai/dsh-agent' import { CallId } from '@deepseek-ai/dsh-llm' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' /** Mount the registry (with its systemPrompt dependency) on a fresh context. */ @@ -37,7 +36,11 @@ function tool(name: string, reply = `ran:${name}`): ToolDefinition { name, description: `tool ${name}`, parameters: { type: 'object', properties: {} }, - execute: (): Promise => Promise.resolve([{ type: 'text', text: reply }]), + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value as string }], + }, + execute: (): Promise => Promise.resolve(reply), } } @@ -221,7 +224,7 @@ describe('scoped execution dispatch', () => { ...tool('t'), execute: () => { bodyCalls += 1 - return Promise.resolve([{ type: 'text', text: 'ran:t' }]) + return Promise.resolve('ran:t') }, }) const guard = (execution: Readonly): string => { @@ -253,7 +256,7 @@ describe('scoped execution dispatch', () => { ...tool('t'), execute: () => { bodyCalls += 1 - return Promise.resolve([]) + return Promise.resolve('ran:t') }, }) ctx.tools.guard(() => undefined) @@ -276,14 +279,14 @@ describe('scoped execution dispatch', () => { execute: (args) => { safeCalls += 1 safeArguments = args - return Promise.resolve([{ type: 'text', text: 'safe' }]) + return Promise.resolve('safe') }, }) ctx.tools.register({ ...tool('danger'), execute: () => { dangerCalls += 1 - return Promise.resolve([{ type: 'text', text: 'danger' }]) + return Promise.resolve('danger') }, }) scope.ctx.tools.guard(exec => exec.name === 'danger' ? 'danger denied' : undefined) @@ -335,7 +338,7 @@ describe('scoped execution dispatch', () => { ...tool('t'), execute: () => { bodyCalls += 1 - return Promise.resolve([]) + return Promise.resolve('ran:t') }, }) ctx.on('tools/pre-execute', (_exec, next) => { @@ -396,7 +399,7 @@ describe('scoped execution dispatch', () => { ...tool('t'), execute: (_args, exec) => { observed.push(exec.parent) - return Promise.resolve([{ type: 'text', text: 'ran:t' }]) + return Promise.resolve('ran:t') }, }) ctx.on('tools/pre-execute', (exec, next) => { @@ -511,7 +514,7 @@ describe('scoped execution dispatch', () => { ...tool('t'), execute: () => { bodyCalls += 1 - return Promise.resolve([]) + return Promise.resolve('ran:t') }, }) ctx.on('tools/pre-execute', (_exec, next) => { @@ -552,6 +555,7 @@ describe('scoped execution dispatch', () => { expect(result).toEqual({ content: [{ type: 'text', text: 'ran:t' }], isError: false, + value: 'ran:t', }) }) @@ -570,6 +574,7 @@ describe('scoped execution dispatch', () => { return { content: [{ type: 'text', text: 'outer failure' }], isError: true, + error: { message: 'outer failure' }, } }, { prepend: true }) scope.ctx.on('tools/result', (_exec, result) => { diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 5ddac2cd31..a8ccf384d5 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -5,9 +5,9 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { Agent } from '@deepseek-ai/dsh-agent' import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval' import ToolRegistry, { - defineTool, JsonSchemaError, parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError, + defineContentToolFixture, defineTool, JsonSchemaError, parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError, type InferArgs, type JsonValue, type ParameterSchemaSpec, type PreToolDecision, type PostToolDecision, - type ToolExecution, type ToolExecutionResult, + type ToolDefinition, type ToolExecution, type ToolExecutionResult, type ToolExecutionToken, } from '@deepseek-ai/dsh-tools' async function setup() { @@ -21,8 +21,12 @@ const echoTool = defineTool({ name: 'echo', description: 'echo arguments back', parameters: { text: { type: 'string' } }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, async execute(args) { - return [{ type: 'text' as const, text: args.text ?? '' }] + return args.text ?? '' }, }) @@ -50,7 +54,7 @@ describe('ToolRegistry', () => { // the system-prompt assembly → the model request, so those callbacks (and // `execute`) must be stripped: a function in the JSON tool schema would // corrupt the request. schemas() is an explicit allowlist, so it can't leak. - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'present', description: 'has presenters', parameters: { x: { type: 'string', required: true } }, @@ -67,7 +71,7 @@ describe('ToolRegistry', () => { it('schemas() excludes timeoutMs — the budget must never reach the model', async () => { const ctx = await setup() - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'budgeted', description: 'has a budget', parameters: {}, timeoutMs: 5_000, async execute() { return [{ type: 'text' as const, text: 'ok' }] }, })) @@ -79,17 +83,24 @@ describe('ToolRegistry', () => { it('executes a tool and returns its content', async () => { const ctx = await setup() ctx.tools.register(echoTool) + let observed: ToolExecutionResult | undefined + ctx.on('tools/result', (_exec, result) => { observed = result }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) - expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false }) + expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false, value: 'hi' }) + expect(observed).toEqual(result) }) - it('threads a tool-attached meta (object return form) onto the result', async () => { + it('projects presentation metadata from the canonical value', async () => { const ctx = await setup() ctx.tools.register({ ...echoTool, name: 'meta-tool', + output: { + ...echoTool.output, + presentationMeta: () => ({ diffs: [{ path: 'a', oldText: null, newText: 'x' }] }), + }, async execute() { - return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a', oldText: null, newText: 'x' }] } } + return 'ok' }, }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'meta-tool', arguments: {} }) @@ -97,20 +108,21 @@ describe('ToolRegistry', () => { content: [{ type: 'text', text: 'ok' }], isError: false, meta: { diffs: [{ path: 'a', oldText: null, newText: 'x' }] }, + value: 'ok', }) }) - it('omits meta when the object return form supplies none', async () => { + it('omits meta when no presentation projector is declared', async () => { const ctx = await setup() ctx.tools.register({ ...echoTool, name: 'no-meta-tool', async execute() { - return { content: [{ type: 'text', text: 'ok' }] } + return 'ok' }, }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'no-meta-tool', arguments: {} }) - expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false }) + expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false, value: 'ok' }) expect('meta' in result).toBe(false) }) @@ -121,8 +133,12 @@ describe('ToolRegistry', () => { ctx.tools.register({ ...echoTool, name: 'bad-meta', + output: { + ...echoTool.output, + presentationMeta: () => (() => undefined) as unknown as JsonValue, + }, async execute() { - return { content: [], meta: () => undefined } + return 'ok' }, }) @@ -134,6 +150,284 @@ describe('ToolRegistry', () => { expect(observedError).toBe(true) }) + it('requires every raw registration to declare its canonical output', async () => { + const ctx = await setup() + const missingOutput = { + name: 'legacy-content-tool', + description: 'missing output', + parameters: {}, + execute: async () => [{ type: 'text', text: 'legacy' }], + } as unknown as ToolDefinition + + expect(() => ctx.tools.register(missingOutput)) + .toThrow('must declare output { schema, render, presentationMeta? }') + }) + + it('rejects lossy and schema-mismatched body values before post-execute', async () => { + const ctx = await setup() + ctx.tools.register(defineTool({ + name: 'lossy-output', + description: 'lossy', + parameters: {}, + output: { schema: { type: 'json' }, render: () => [] }, + execute: async () => (() => undefined) as unknown as JsonValue, + })) + ctx.tools.register(defineTool({ + name: 'wrong-output', + description: 'wrong schema', + parameters: {}, + output: { schema: { type: 'string' }, render: () => [] }, + execute: async () => 42 as unknown as string, + })) + + const lossy = await ctx.tools.execute({ callId: CallId('lossy'), name: 'lossy-output', arguments: {} }) + const mismatch = await ctx.tools.execute({ callId: CallId('mismatch'), name: 'wrong-output', arguments: {} }) + expect(lossy.error).toMatchObject({ info: { name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' } }) + expect(lossy.content[0]?.type === 'text' ? lossy.content[0].text : '').toContain('not lossless JSON') + expect(mismatch.error).toMatchObject({ info: { name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' } }) + expect(mismatch.content[0]?.type === 'text' ? mismatch.content[0].text : '').toContain('"value" must be a string') + }) + + it.each(['render', 'presentationMeta'] as const)('contains a throwing output.%s projector as one failed call', async (projector) => { + const ctx = await setup() + ctx.tools.register(defineTool({ + name: `throwing-${projector}`, + description: projector, + parameters: {}, + output: { + schema: { type: 'string' }, + render: () => { + if (projector === 'render') throw new Error('renderer exploded') + return [{ type: 'text', text: 'ok' }] + }, + presentationMeta: () => { + if (projector === 'presentationMeta') throw new Error('metadata exploded') + return null + }, + }, + execute: async () => 'ok', + })) + + const result = await ctx.tools.execute({ callId: CallId(projector), name: `throwing-${projector}`, arguments: {} }) + expect(result).toMatchObject({ + isError: true, + error: { message: projector === 'render' ? 'renderer exploded' : 'metadata exploded' }, + }) + expect('value' in result).toBe(false) + }) + + it('keeps value/meta through content replacement and recomputes both projections after value replacement', async () => { + const ctx = await setup() + ctx.tools.register(defineTool({ + name: 'projected', + description: 'projected', + parameters: {}, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { text: { type: 'string', required: true } }, + }, + render: (_args, value) => [{ type: 'text', text: `render:${value.text}` }], + presentationMeta: (_args, value) => ({ projected: value.text }), + }, + execute: async () => ({ text: 'body' }), + })) + let replacement: 'content' | 'value' = 'content' + ctx.on('tools/post-execute', async () => { + if (replacement === 'content') { + return { kind: 'accept', content: [{ type: 'text', text: 'policy content' }] } + } + return { + kind: 'accept', + value: { text: 'policy value' }, + additionalContexts: [{ content: [{ type: 'text', text: 'value context' }], source: { kind: 'plugin', plugin: 'test' } }], + } + }) + + const content = await ctx.tools.execute({ callId: CallId('content'), name: 'projected', arguments: {} }) + replacement = 'value' + const value = await ctx.tools.execute({ callId: CallId('value'), name: 'projected', arguments: {} }) + + expect(content).toEqual({ + isError: false, + value: { text: 'body' }, + content: [{ type: 'text', text: 'policy content' }], + meta: { projected: 'body' }, + }) + expect(value).toEqual({ + isError: false, + value: { text: 'policy value' }, + content: [{ type: 'text', text: 'render:policy value' }], + meta: { projected: 'policy value' }, + additionalContexts: [{ content: [{ type: 'text', text: 'value context' }], source: { kind: 'plugin', plugin: 'test' } }], + }) + }) + + it('fails a post-execute decision that replaces both projections or supplies an invalid value', async () => { + const both = await setup() + both.tools.register(echoTool) + both.on('tools/post-execute', async () => ({ + kind: 'accept', + value: 'replacement', + content: [{ type: 'text', text: 'also replacement' }], + } as unknown as PostToolDecision)) + const bothResult = await both.tools.execute({ callId: CallId('both'), name: 'echo', arguments: {} }) + expect(bothResult).toMatchObject({ + isError: true, + error: { message: 'tools/post-execute accept decision cannot replace both value and content' }, + }) + + const invalid = await setup() + invalid.tools.register(echoTool) + invalid.on('tools/post-execute', async () => ({ kind: 'accept', value: 1 })) + const invalidResult = await invalid.tools.execute({ callId: CallId('invalid'), name: 'echo', arguments: {} }) + expect(invalidResult.error).toMatchObject({ info: { code: 'INVALID_TOOL_OUTPUT' } }) + expect('value' in invalidResult).toBe(false) + }) + + it('turns a post-execute block into a valueless failure', async () => { + const ctx = await setup() + ctx.tools.register(echoTool) + ctx.on('tools/post-execute', async () => ({ + kind: 'block', + feedback: [{ type: 'text', text: 'blocked by policy' }], + })) + + const result = await ctx.tools.execute({ callId: CallId('block'), name: 'echo', arguments: { text: 'secret' } }) + expect(result).toEqual({ + isError: true, + error: { message: 'blocked by policy' }, + content: [{ type: 'text', text: 'blocked by policy' }], + }) + expect('value' in result).toBe(false) + }) + + it('replaces a canonical value without manufacturing additional context', async () => { + const ctx = await setup() + ctx.tools.register(echoTool) + ctx.on('tools/post-execute', async () => ({ kind: 'accept', value: 'replacement' })) + + const result = await ctx.tools.execute({ callId: CallId('replace-value'), name: 'echo', arguments: {} }) + expect(result).toEqual({ + isError: false, + value: 'replacement', + content: [{ type: 'text', text: 'replacement' }], + }) + }) + + it.each([ + [[], 'tool result blocked by post-execute policy'], + [[{ type: 'reasoning', text: 'private rationale' }], '[reasoning content]'], + ] as const)('derives a stable failure message from non-text or empty block feedback', async (feedback, message) => { + const ctx = await setup() + ctx.tools.register(echoTool) + ctx.on('tools/post-execute', async () => ({ kind: 'block', feedback: [...feedback] })) + + const result = await ctx.tools.execute({ callId: CallId('block-message'), name: 'echo', arguments: {} }) + expect(result.error?.message).toBe(message) + }) + + it('contains a non-JSON post-execute failure projection as a safe final error', async () => { + const ctx = await setup() + ctx.tools.register(echoTool) + ctx.on('tools/post-execute', async () => ({ + kind: 'block', + feedback: [{ type: 'text', text: 'blocked', invalid: () => undefined } as never], + })) + + const result = await ctx.tools.execute({ callId: CallId('invalid-block'), name: 'echo', arguments: {} }) + expect(result).toMatchObject({ + isError: true, + error: { message: 'tool result must be losslessly JSON-serializable' }, + }) + }) + + it('rejects value replacement on a failed dispatch', async () => { + const ctx = await setup() + ctx.tools.register({ + ...echoTool, + name: 'throw-before-replace', + async execute() { throw new Error('body failed') }, + }) + ctx.on('tools/post-execute', async () => ({ kind: 'accept', value: 'replacement' })) + + const result = await ctx.tools.execute({ + callId: CallId('failed-replace'), name: 'throw-before-replace', arguments: {}, + }) + expect(result.error?.message).toBe('tools/post-execute cannot replace the value of a failed result') + }) + + it('fails value replacement when the owning tool disappears before post-policy resolves', async () => { + const ctx = await setup() + const dispose = ctx.tools.register(echoTool) + ctx.on('tools/post-execute', async () => { + dispose() + return { kind: 'accept', value: 'replacement' } + }) + + const result = await ctx.tools.execute({ callId: CallId('post-disposed'), name: 'echo', arguments: {} }) + expect(result.error).toEqual({ + message: 'unknown tool "echo"', + info: { name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' }, + }) + }) + + it('normalizes wrapper-authored failure metadata and contexts', async () => { + const ctx = await setup() + ctx.tools.register(echoTool) + ctx.on('tools/execute', async () => ({ + isError: true, + error: { message: 'wrapped failure' }, + content: [{ type: 'text', text: 'wrapper content' }], + meta: { wrapped: true }, + additionalContexts: [{ content: [{ type: 'text', text: 'wrapper context' }], source: { kind: 'plugin', plugin: 'test' } }], + })) + + const result = await ctx.tools.execute({ callId: CallId('wrapper-failure'), name: 'echo', arguments: {} }) + expect(result).toEqual({ + isError: true, + error: { message: 'wrapped failure' }, + content: [{ type: 'text', text: 'wrapper content' }], + meta: { wrapped: true }, + additionalContexts: [{ content: [{ type: 'text', text: 'wrapper context' }], source: { kind: 'plugin', plugin: 'test' } }], + }) + }) + + it('fails wrapper-authored success normalization when the owning tool disappears', async () => { + const ctx = await setup() + const dispose = ctx.tools.register(echoTool) + ctx.on('tools/execute', async () => { + dispose() + return { isError: false, value: 'replacement', content: [] } + }) + + const result = await ctx.tools.execute({ callId: CallId('wrapper-disposed'), name: 'echo', arguments: {} }) + expect(result.error).toEqual({ + message: 'unknown tool "echo"', + info: { name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' }, + }) + }) + + it('suppresses presentation metadata only for nested composite dispatches', async () => { + const ctx = await setup() + ctx.tools.register({ + ...echoTool, + name: 'meta-suppression', + output: { ...echoTool.output, presentationMeta: () => ({ card: true }) }, + }) + const direct = await ctx.tools.execute({ callId: CallId('direct'), name: 'meta-suppression', arguments: {} }) + const nested = await ctx.tools.execute({ + callId: CallId('nested'), + name: 'meta-suppression', + arguments: {}, + parent: Symbol('outer') as ToolExecutionToken, + }) + expect(direct.meta).toEqual({ card: true }) + expect(nested.meta).toBeUndefined() + expect(nested.isError ? undefined : nested.value).toBe('') + }) + it('returns isError results for unknown tools and throwing tools', async () => { const ctx = await setup() ctx.tools.register({ @@ -148,7 +442,10 @@ describe('ToolRegistry', () => { expect(unknown.isError).toBe(true) expect(unknown.content[0]).toMatchObject({ text: 'Error: unknown tool "nope"' }) // An unknown tool is a routable failure class, same as a tool-thrown one. - expect(unknown.error).toEqual({ name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' }) + expect(unknown.error).toEqual({ + message: 'unknown tool "nope"', + info: { name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' }, + }) const thrown = await ctx.tools.execute({ callId: CallId('c2'), name: 'boom', arguments: {} }) expect(thrown.isError).toBe(true) @@ -189,15 +486,22 @@ describe('ToolRegistry', () => { it('lets a tools/pre-execute listener deny a call (permission pattern)', async () => { const ctx = await setup() ctx.tools.register(echoTool) + let postSawFrozen = false ctx.on('tools/pre-execute', async (exec, next): Promise => { if (exec.name === 'echo') return { kind: 'deny', reason: 'denied by policy' } return next() }) + ctx.on('tools/post-execute', async (_exec, result, next) => { + postSawFrozen = Object.isFrozen(result) + expect(Reflect.set(result, 'content', [])).toBe(false) + return next() + }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: denied by policy' }) + expect(postSawFrozen).toBe(true) }) it('an ask decision degrades to deny when no approval seam is mounted', async () => { @@ -378,7 +682,7 @@ describe('ToolRegistry', () => { it('preserves tool-deferred, execute-wrapper, and post-execute contexts in order', async () => { const ctx = await setup() - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'composite', description: 'composite', parameters: {}, @@ -422,7 +726,7 @@ describe('ToolRegistry', () => { it('keeps deferred contexts when a composite tool throws, but drops them when the outer call is blocked', async () => { const ctx = await setup() - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'failing-composite', description: 'failing composite', parameters: {}, @@ -473,7 +777,7 @@ describe('ToolRegistry', () => { it('runs tools/execute after an allowed pre-execute, around dispatch, and before post-execute', async () => { const ctx = await setup() const order: string[] = [] - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'traced', description: 'echo', parameters: { text: { type: 'string' } }, @@ -493,7 +797,7 @@ describe('ToolRegistry', () => { ctx.on('tools/post-execute', async (_exec, _result, next) => { order.push('post'); return next() }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'traced', arguments: { text: 'hi' } }) - expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false }) + expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false, value: [{ type: 'text', text: 'hi' }] }) // The around seam wraps dispatch; pre gates before it, post runs over its result. expect(order).toEqual(['pre', 'execute:before', 'dispatch', 'execute:after', 'post']) }) @@ -533,11 +837,35 @@ describe('ToolRegistry', () => { }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'boom', arguments: {} }) - expect(seen).toEqual({ isError: true, error: { name: 'HarnessError', code: 'BOOM' } }) + expect(seen).toEqual({ + isError: true, + error: { message: 'kaboom', info: { name: 'HarnessError', code: 'BOOM' } }, + }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: kaboom' }) }) + it('freezes core dispatch outcomes before around and post listeners can observe them', async () => { + const ctx = await setup() + ctx.tools.register(echoTool) + const mutationAttempts: boolean[] = [] + ctx.on('tools/execute', async (_exec, next) => { + const result = await next() + mutationAttempts.push(Reflect.set(result, 'value', 'around mutation')) + return result + }) + ctx.on('tools/post-execute', async (_exec, result, next) => { + mutationAttempts.push(Reflect.set(result, 'value', 'post mutation')) + return next() + }) + + const result = await ctx.tools.execute({ + callId: CallId('frozen-canonical'), name: 'echo', arguments: { text: 'original' }, + }) + expect(mutationAttempts).toEqual([false, false]) + expect(result.isError ? undefined : result.value).toBe('original') + }) + it('a thrown tool normalized inside tools/execute still reaches post-execute', async () => { const ctx = await setup() ctx.tools.register({ @@ -567,7 +895,7 @@ describe('ToolRegistry', () => { name: 'signal-probe', async execute(_args, exec) { seenSignal = exec.signal - return [{ type: 'text' as const, text: 'ok' }] + return 'ok' }, }) @@ -591,11 +919,11 @@ describe('ToolRegistry', () => { ctx.tools.register({ ...echoTool, name: 'never-runs', - async execute() { dispatched = true; return [] }, + async execute() { dispatched = true; return 'unreachable' }, }) ctx.on('tools/execute', async (_exec: ToolExecution, _next: () => Promise): Promise => - ({ content: [{ type: 'text', text: 'short-circuited' }], isError: false })) + ({ content: [{ type: 'text', text: 'ignored authored content' }], isError: false, value: 'short-circuited' })) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'never-runs', arguments: {} }) expect(dispatched).toBe(false) // returning without next() skips core dispatch @@ -608,6 +936,7 @@ describe('ToolRegistry', () => { ctx.on('tools/execute', async () => ({ content: [{ type: 'text', text: 'short-circuited with context' }], isError: false, + value: 'short-circuited with context', additionalContexts: [{ content: [{ type: 'text', text: 'from around dispatch' }], source: { kind: 'plugin', plugin: 'test' }, @@ -631,6 +960,7 @@ describe('ToolRegistry', () => { const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result).toEqual({ content: [{ type: 'text', text: 'Error: wrapper broke' }], + error: { message: 'wrapper broke' }, isError: true, }) }) @@ -646,6 +976,7 @@ describe('ToolRegistry', () => { expect(result).toEqual({ content: [{ type: 'text', text: 'Error: permission hook broke' }], + error: { message: 'permission hook broke' }, isError: true, }) }) @@ -661,6 +992,7 @@ describe('ToolRegistry', () => { expect(result).toEqual({ content: [{ type: 'text', text: 'Error: post hook broke' }], + error: { message: 'post hook broke' }, isError: true, }) }) @@ -676,7 +1008,7 @@ describe('ToolRegistry', () => { expect(result).toMatchObject({ isError: true, - error: { name: 'HarnessError', code: 'DENIED' }, + error: { message: 'denied', info: { name: 'HarnessError', code: 'DENIED' } }, }) }) @@ -839,10 +1171,14 @@ describe('defineTool / schema DSL', () => { text: { type: 'string', required: true }, uppercase: { type: 'boolean' }, }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, async execute(args) { // args is typed: { text: string; uppercase?: boolean } const result = args.uppercase ? args.text.toUpperCase() : args.text - return [{ type: 'text', text: result }] + return result }, }) @@ -866,6 +1202,7 @@ describe('defineTool / schema DSL', () => { arguments: { text: 'hello', uppercase: true }, }) expect(result.isError).toBe(false) + expect(result.isError ? undefined : result.value).toBe('HELLO') expect(result.content).toEqual([{ type: 'text', text: 'HELLO' }]) }) @@ -876,12 +1213,13 @@ describe('defineTool / schema DSL', () => { name: 'type-check', description: '', parameters: { a: { type: 'string' as const, required: true as const }, b: { type: 'number' as const } }, + output: { schema: { type: 'string' }, render: () => [] }, async execute(args) { // Verify types at runtime via typeof expect(typeof args.a).toBe('string') // args.b should be undefined when not provided void args - return [{ type: 'text', text: args.a }] + return args.a }, }) void tool @@ -896,8 +1234,12 @@ describe('defineTool / schema DSL', () => { req: { type: 'string', required: true }, opt: { type: 'number', description: 'Optional number' }, }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, async execute(args) { - return [{ type: 'text', text: `${args.req}:${args.opt ?? 'none'}` }] + return `${args.req}:${args.opt ?? 'none'}` }, })) @@ -933,9 +1275,13 @@ describe('defineTool / schema DSL', () => { properties: { path: { type: 'string' } }, required: ['path'], }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value as string }], + }, async execute(args: unknown) { const p = args as { path: string } - return [{ type: 'text', text: p.path }] + return p.path }, }) @@ -1284,7 +1630,7 @@ describe('validateArgs (the runtime-validation Agent Note, part 1)', () => { describe('defineTool validation (the runtime-validation Agent Note, part 1)', () => { it('returns an isError result with the violations when the model sends bad args', async () => { const ctx = await setup() - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'reader', description: 'reads a path', parameters: { path: { type: 'string', required: true } }, @@ -1302,7 +1648,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () it('runs execute normally when args are valid', async () => { const ctx = await setup() - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'reader', description: 'reads a path', parameters: { path: { type: 'string', required: true } }, @@ -1311,7 +1657,11 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () }, })) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: { path: '/x' } }) - expect(result).toEqual({ content: [{ type: 'text', text: 'read /x' }], isError: false }) + expect(result).toEqual({ + content: [{ type: 'text', text: 'read /x' }], + isError: false, + value: [{ type: 'text', text: 'read /x' }], + }) }) it('ToolArgsError carries a stable code and the violation list', () => { @@ -1325,7 +1675,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () it('a schema-invalid call surfaces the structured error on the result', async () => { const ctx = await setup() - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'reader', description: 'reads a path', parameters: { path: { type: 'string', required: true } }, @@ -1335,7 +1685,10 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () })) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: {} }) expect(result.isError).toBe(true) - expect(result.error).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) + expect(result.error).toEqual({ + message: 'invalid arguments: missing required property "path"', + info: { name: 'ToolArgsError', code: 'INVALID_ARGS' }, + }) }) it('a tool throwing a HarnessError surfaces its name and code', async () => { @@ -1350,11 +1703,11 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'coded', arguments: {} }) expect(result.isError).toBe(true) - expect(result.error).toEqual({ name: 'HarnessError', code: 'ENOSPC' }) + expect(result.error).toEqual({ message: 'disk full', info: { name: 'HarnessError', code: 'ENOSPC' } }) expect(result.content[0]).toMatchObject({ text: 'Error: disk full' }) }) - it('a non-HarnessError throw has no structured error (only the text)', async () => { + it('a non-HarnessError throw retains only its message', async () => { const ctx = await setup() ctx.tools.register({ ...echoTool, @@ -1365,7 +1718,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'plain', arguments: {} }) expect(result.isError).toBe(true) - expect(result.error).toBeUndefined() + expect(result.error).toEqual({ message: 'just a message' }) expect(result.content[0]).toMatchObject({ text: 'Error: just a message' }) }) @@ -1376,8 +1729,12 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () name: 'raw', description: 'raw tool', parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value as string }], + }, async execute(args: unknown) { - return [{ type: 'text', text: typeof args }] + return typeof args }, }) // Missing the "required" path — but raw tools validate their own input, so @@ -1387,7 +1744,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () }) it('attaches a positive-finite timeoutMs to the definition', () => { - const tool = defineTool({ + const tool = defineContentToolFixture({ name: 'x', description: 'd', parameters: {}, timeoutMs: 30_000, async execute() { return [{ type: 'text' as const, text: 'ok' }] }, }) @@ -1395,7 +1752,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () }) it('omits timeoutMs when not declared', () => { - const tool = defineTool({ + const tool = defineContentToolFixture({ name: 'x', description: 'd', parameters: {}, async execute() { return [{ type: 'text' as const, text: 'ok' }] }, }) @@ -1403,7 +1760,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () }) it('throws when timeoutMs is zero or negative', () => { - const make = (ms: number) => defineTool({ + const make = (ms: number) => defineContentToolFixture({ name: 'x', description: 'd', parameters: {}, timeoutMs: ms, async execute() { return [{ type: 'text' as const, text: 'ok' }] }, }) @@ -1412,7 +1769,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () }) it('throws when timeoutMs is non-finite', () => { - expect(() => defineTool({ + expect(() => defineContentToolFixture({ name: 'x', description: 'd', parameters: {}, timeoutMs: Infinity, async execute() { return [{ type: 'text' as const, text: 'ok' }] }, })).toThrow('positive finite number') @@ -1421,7 +1778,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () describe('defineTool presentation (presentCall / presentResult)', () => { it('threads presentCall/presentResult onto the ToolDefinition with typed args', () => { - const tool = defineTool({ + const tool = defineContentToolFixture({ name: 'demo', description: 'demo', parameters: { path: { type: 'string', required: true }, n: { type: 'number' } }, @@ -1441,7 +1798,7 @@ describe('defineTool presentation (presentCall / presentResult)', () => { }) it('a tool without presentCall/presentResult leaves them undefined (UI falls back generically)', () => { - const tool = defineTool({ + const tool = defineContentToolFixture({ name: 'plain', description: 'plain', parameters: { x: { type: 'string', required: true } }, @@ -1452,7 +1809,7 @@ describe('defineTool presentation (presentCall / presentResult)', () => { }) it('presentCall/presentResult validate softly: malformed args return undefined, never throw (display runs on replay)', () => { - const tool = defineTool({ + const tool = defineContentToolFixture({ name: 'demo', description: 'demo', parameters: { path: { type: 'string', required: true } }, diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index 52ad60449e..08c3927fe5 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -203,7 +203,8 @@ describe('dsh-acp-demo composition', () => { name, description: name, parameters: {}, - execute: async () => [], + output: { schema: { type: 'null' }, render: () => [] }, + execute: async () => null, }) } const assembly = await ctx.get('systemPrompt')!.assemble() diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index c8788ca01b..19c35b5624 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -494,7 +494,8 @@ describe('dsh-agent-spine-demo bundle', () => { name, description: name, parameters: {}, - execute: async () => [], + output: { schema: { type: 'null' }, render: () => [] }, + execute: async () => null, }) } const assembly = await ctx.get('systemPrompt')!.assemble() diff --git a/packages/examples/cli-demo/tests/cli-demo.spec.ts b/packages/examples/cli-demo/tests/cli-demo.spec.ts index c248242ad1..1433645eb5 100644 --- a/packages/examples/cli-demo/tests/cli-demo.spec.ts +++ b/packages/examples/cli-demo/tests/cli-demo.spec.ts @@ -95,7 +95,13 @@ describe('dsh-cli-demo app composition', () => { }) ctx.skills.register({ name: 'cli-skill', description: 'CLI skill', source: 'runtime', content: 'body' }) for (const name of ['alpha', 'zulu']) { - ctx.tools.register({ name, description: name, parameters: {}, execute: async () => [] }) + ctx.tools.register({ + name, + description: name, + parameters: {}, + output: { schema: { type: 'null' }, render: () => [] }, + execute: async () => null, + }) } expect(JSON.stringify(await composePrefix(ctx))).toContain('- `cli-skill`: CLI...') expect((await ctx.systemPrompt.assemble()).tools.map(tool => tool.name)).toEqual([ diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts index f038efe67e..8de89e63e6 100644 --- a/packages/examples/cli-demo/tests/cli.spec.ts +++ b/packages/examples/cli-demo/tests/cli.spec.ts @@ -115,7 +115,11 @@ async function harness(script: readonly ScriptEntry[]): Promise { name: 'echo', description: 'Echo text.', parameters: { text: { type: 'string', required: true } }, - execute: async args => [{ type: 'text', text: `ECHO: ${(args as { text: string }).text}` }], + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value as string }], + }, + execute: async args => `ECHO: ${(args as { text: string }).text}`, }) const [agent] = ctx.agents.roots() if (agent === undefined) throw new Error('test main agent missing') diff --git a/packages/fs/tool-fs-search/README.md b/packages/fs/tool-fs-search/README.md index 7b254fd59e..5d0655bcc9 100644 --- a/packages/fs/tool-fs-search/README.md +++ b/packages/fs/tool-fs-search/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-tool-fs-search -The **model-facing filesystem discovery tools** — `glob`, `grep` — backed by the **bash executor seam**, not by `ctx.fs` provider methods. At load, the package probes `command -v rg` through `ctx.bash`; if the executor cannot find ripgrep on its `PATH`, it logs a warning and registers no tools or prompt sections. Each call assembles a fixed ripgrep command (every model-controlled value through one package-private shell-quoting helper), runs it via `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` as an ordinary foreground tool call, parses the raw `rg` output, and returns a bounded, workdir-relative result. The package injects `tools`, `systemPrompt`, and `bash` — deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional. +The **model-facing filesystem discovery tools**—`glob`, `grep`—are backed by the **bash executor seam**, not by `ctx.fs` provider methods. At load, the package probes `command -v rg` through `ctx.bash`; if the executor cannot find ripgrep on its `PATH`, it logs a warning and registers no tools or prompt sections. Each call assembles a fixed ripgrep command (every model-controlled value through one package-private shell-quoting helper), runs it via `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` as an ordinary foreground tool call, parses the raw `rg` output, and returns a workdir-relative canonical value. The package injects `tools`, `systemPrompt`, and `bash`—deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional. ```ts ignore-check // Default deployment: a bash executor whose PATH includes rg, then the discovery tools. @@ -39,7 +39,7 @@ Routine budgets stay out of the model-facing schema (no `head_limit`/`offset`/`c ## Two budgets, two artifacts -Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam and parses only complete retained stdout; if the executor still returns `stdout.truncated`, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. The model-facing recovery artifact is different: when a search yields more logical results than the inline cap, the tool saves the COMPLETE formatted result through `ctx.spillStore.saveText()` (suggested names `glob-results.txt` / `grep-results.txt`, owner = the calling session, source = the tool execution identity) and appends a footer naming the returned locator and retrieval hint. This is the first tool-owned spill call in the codebase — deliberate, because retention here is item-level: the generic `@deepseek-ai/dsh-spill-policy` only sees the final text on `tools/post-execute`, by which point a capped search has already omitted later paths/matches. A missing spill backend, a call with no session owner, or a `saveText()` failure keeps the inline page and reports that the complete result could not be saved — never an `isError`. +Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam and parses only complete retained stdout; if the executor still returns `stdout.truncated`, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. A successful `glob` keeps every acquired path in `{ paths }`; `grep` keeps every acquired `{ path, lineNumber, line }` in `{ matches }`. Inline item and per-line preview caps apply only in the Native renderer. For a direct surface call with more logical results than the inline cap, post-policy best-effort saves the complete formatted preview through `ctx.spillStore.saveText()` and replaces only presentation with a head page plus locator. Nested Code dispatches skip that spill because their full canonical value does not enter model context. Missing/failed spill keeps the inline page and reports that the complete result could not be saved—never an `isError`. ## Errors diff --git a/packages/fs/tool-fs-search/src/glob.ts b/packages/fs/tool-fs-search/src/glob.ts index a3e803fb50..6d42acee66 100644 --- a/packages/fs/tool-fs-search/src/glob.ts +++ b/packages/fs/tool-fs-search/src/glob.ts @@ -12,7 +12,6 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView } from '@deepseek-ai/dsh-tools' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { ItemRetainer } from '@deepseek-ai/dsh-retention' import type { RetainedItems } from '@deepseek-ai/dsh-retention' import type { SpillRef } from '@deepseek-ai/dsh-spill' @@ -20,6 +19,7 @@ import type {} from '@deepseek-ai/dsh-bash' import type {} from '@deepseek-ai/dsh-system-prompt' import { runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts' import { singleQuote } from './shell-quote.ts' +import { acceptedSurfaceValue } from './surface.ts' /** * Default cap on paths retained inline by one `glob` call (the `globMaxResults` @@ -117,6 +117,14 @@ export function formatGlobOutput(retained: RetainedItems, spillRef: Spil return `${body}\n\n(Showing ${retained.kept} of ${retained.seen} paths. ${recovery})` } +/** Retain and format one canonical path list for the Native surface. */ +function renderGlobPaths(paths: string[], maxResults: number, spillRef?: SpillRef): string { + if (paths.length === 0) return 'No files found' + const retainer = new ItemRetainer({ kind: 'head', maxItems: maxResults }) + for (const path of paths) retainer.push(path) + return formatGlobOutput(retainer.finish(), spillRef) +} + /** * Pending-call presentation: a search card titled by the pattern (and root). * @@ -142,7 +150,7 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void { text: 'Use the glob tool — not shell find or ls — to discover files by path pattern. Results are sorted by modification time and include hidden and ignored files.', }) - ctx.tools.register(defineTool({ + const tool = defineTool({ name: 'glob', description: 'Find files whose paths match a glob pattern. Returns matching paths sorted by modification time, ' + 'including hidden and ignored files (VCS metadata directories are excluded). ' @@ -152,28 +160,44 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void { path: { type: 'string', description: 'Directory to search in. Defaults to the session workspace; a relative path resolves against it.' }, }, timeoutMs: caps.timeoutMs, - async execute(args, exec): Promise { + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + paths: { type: 'array', required: true, items: { type: 'string' } }, + }, + }, + render: (_args, value) => [{ type: 'text', text: renderGlobPaths(value.paths, caps.maxResults) }], + }, + async execute(args, exec) { const input = parseGlobArgs(args) const run = await runRipgrep(ctx, exec, 'glob', buildGlobCommand(input), caps.rawOutputMaxBytes) - if (run.noMatches) return [{ type: 'text', text: 'No files found' }] + if (run.noMatches) return { paths: [] } - const retainer = new ItemRetainer({ kind: 'head', maxItems: caps.maxResults }) const all: string[] = [] for (const line of run.stdout.split('\n')) { if (line.length === 0) continue const displayPath = toWorkdirRelative(line, run.workdir) all.push(displayPath) - retainer.push(displayPath) } - const retained = retainer.finish() - - // The complete sorted list is the recovery artifact; save it only when - // the inline page omitted paths (an uncapped result needs no spill file). - const spillRef = retained.truncated - ? await trySaveFormattedResult(ctx, exec, 'glob-results.txt', all.join('\n')) - : undefined - return [{ type: 'text', text: formatGlobOutput(retained, spillRef) }] + return { paths: all } }, presentCall: presentGlobCall, - })) + }) + ctx.tools.register(tool) + + ctx.on('tools/post-execute', async (exec, result, next) => { + const decision = await next() + const value = acceptedSurfaceValue(ctx, tool, exec, result, decision) as { paths: string[] } | undefined + if (value === undefined) return decision + const paths = value.paths + if (paths.length <= caps.maxResults) return decision + const spillRef = await trySaveFormattedResult(ctx, exec, 'glob-results.txt', paths.join('\n')) + return { + kind: 'accept', + content: [{ type: 'text', text: renderGlobPaths(paths, caps.maxResults, spillRef) }], + ...decision.additionalContexts !== undefined ? { additionalContexts: decision.additionalContexts } : {}, + } + }) } diff --git a/packages/fs/tool-fs-search/src/grep.ts b/packages/fs/tool-fs-search/src/grep.ts index 3935513b73..aa82749f3f 100644 --- a/packages/fs/tool-fs-search/src/grep.ts +++ b/packages/fs/tool-fs-search/src/grep.ts @@ -13,7 +13,6 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView } from '@deepseek-ai/dsh-tools' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-retention' import type { RetainedItems } from '@deepseek-ai/dsh-retention' import type { SpillRef } from '@deepseek-ai/dsh-spill' @@ -21,6 +20,7 @@ import type {} from '@deepseek-ai/dsh-bash' import type {} from '@deepseek-ai/dsh-system-prompt' import { SearchError, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts' import { singleQuote } from './shell-quote.ts' +import { acceptedSurfaceValue } from './surface.ts' /** * Default cap on flat matches retained inline by one `grep` call (the @@ -241,6 +241,20 @@ export function formatGrepOutput(retained: RetainedItems, spillRef: S return `${header}\n\n${body}\n\n(${recovery})` } +/** Apply the Native per-line preview budget without changing the canonical matches. */ +function previewGrepMatches(matches: GrepMatch[], maxLineBytes: number): GrepMatch[] { + return matches.map(match => ({ ...match, line: previewLine(match.line, maxLineBytes) })) +} + +/** Retain and format one canonical match list for the Native surface. */ +function renderGrepMatches(matches: GrepMatch[], maxMatches: number, maxLineBytes: number, spillRef?: SpillRef): string { + if (matches.length === 0) return 'No matches found' + const previewed = previewGrepMatches(matches, maxLineBytes) + const retainer = new ItemRetainer({ kind: 'head', maxItems: maxMatches }) + for (const match of previewed) retainer.push(match) + return formatGrepOutput(retainer.finish(), spillRef) +} + /** * Pending-call presentation: a search card titled by the pattern (and target / * include filter). @@ -268,7 +282,7 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void { text: 'Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context.', }) - ctx.tools.register(defineTool({ + const tool = defineTool({ name: 'grep', description: 'Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. ' + `Returns the first ${caps.maxMatches} matches inline; a capped result reports where the complete match list was saved. ` @@ -279,37 +293,70 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void { include: { type: 'string', description: 'One glob filter for which files to search (e.g. "*.ts", "*.{js,jsx}"). Not a list; negation is not supported.' }, }, timeoutMs: caps.timeoutMs, - async execute(args, exec): Promise { + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + matches: { + type: 'array', + required: true, + items: { + type: 'object', + additionalProperties: false, + properties: { + path: { type: 'string', required: true }, + lineNumber: { type: 'integer', required: true }, + line: { type: 'string', required: true }, + }, + }, + }, + }, + }, + render: (_args, value) => [{ + type: 'text', + text: renderGrepMatches(value.matches, caps.maxMatches, caps.maxLineBytes), + }], + }, + async execute(args, exec) { const input = parseGrepArgs(args) const run = await runRipgrep(ctx, exec, 'grep', buildGrepCommand(input), caps.rawOutputMaxBytes) - if (run.noMatches) return [{ type: 'text', text: 'No matches found' }] + if (run.noMatches) return { matches: [] } - const retainer = new ItemRetainer({ kind: 'head', maxItems: caps.maxMatches }) const all: GrepMatch[] = [] for (const raw of parseGrepMatches(run.stdout)) { const match: GrepMatch = { path: toWorkdirRelative(raw.path, run.workdir), lineNumber: raw.lineNumber, - line: previewLine(raw.line, caps.maxLineBytes), + line: raw.line, } all.push(match) - retainer.push(match) } - const retained = retainer.finish() - - // The spill file stores the FULL formatted match list (same grouped, - // per-line-previewed shape the model saw), so read offset/limit pages the - // same logical result; save only when the inline page omitted matches. - const spillRef = retained.truncated - ? await trySaveFormattedResult( - ctx, - exec, - 'grep-results.txt', - `Found ${all.length} ${matchNoun(all.length)}\n\n${formatGrepMatches(all)}`, - ) - : undefined - return [{ type: 'text', text: formatGrepOutput(retained, spillRef) }] + return { matches: all } }, presentCall: presentGrepCall, - })) + }) + ctx.tools.register(tool) + + ctx.on('tools/post-execute', async (exec, result, next) => { + const decision = await next() + const value = acceptedSurfaceValue(ctx, tool, exec, result, decision) as { matches: GrepMatch[] } | undefined + if (value === undefined) return decision + const matches = value.matches + if (matches.length <= caps.maxMatches) return decision + const spillRef = await trySaveFormattedResult( + ctx, + exec, + 'grep-results.txt', + `Found ${matches.length} ${matchNoun(matches.length)}\n\n${formatGrepMatches(previewGrepMatches(matches, caps.maxLineBytes))}`, + ) + return { + kind: 'accept', + content: [{ + type: 'text', + text: renderGrepMatches(matches, caps.maxMatches, caps.maxLineBytes, spillRef), + }], + ...decision.additionalContexts !== undefined ? { additionalContexts: decision.additionalContexts } : {}, + } + }) } diff --git a/packages/fs/tool-fs-search/src/surface.ts b/packages/fs/tool-fs-search/src/surface.ts new file mode 100644 index 0000000000..78bdd6cc42 --- /dev/null +++ b/packages/fs/tool-fs-search/src/surface.ts @@ -0,0 +1,27 @@ +/** Shared surface-only post-policy selection for search result spill. @module dsh-tool-fs-search/surface */ + +import type { Context } from 'cordis' +import type { JsonValue, PostToolDecision, ToolDefinition, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' + +/** + * Return the accepted canonical value only when this tool still owns a direct + * successful surface call and no downstream policy replaced either projection. + * @param ctx - the tool plugin context used to resolve the live scoped owner. + * @param tool - the exact registered definition whose value may be projected. + * @param exec - the completed execution identity. + * @param result - the canonical result before post-policy decisions are applied. + * @param decision - the composed downstream post-policy decision. + * @returns the canonical value to project, or `undefined` when spill must defer. + */ +export function acceptedSurfaceValue( + ctx: Context, + tool: ToolDefinition, + exec: ToolExecution, + result: ToolExecutionResult, + decision: PostToolDecision, +): JsonValue | undefined { + if (decision.kind !== 'accept' || decision.content !== undefined || Object.hasOwn(decision, 'value') + || exec.parent !== undefined || exec.name !== tool.name || result.isError + || ctx.tools.get(exec.name, exec.agent) !== tool) return undefined + return result.value +} diff --git a/packages/fs/tool-fs-search/tests/integration.spec.ts b/packages/fs/tool-fs-search/tests/integration.spec.ts index 36fb2c28e6..31c30d5b15 100644 --- a/packages/fs/tool-fs-search/tests/integration.spec.ts +++ b/packages/fs/tool-fs-search/tests/integration.spec.ts @@ -96,7 +96,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () it('classifies an invalid glob as SEARCH_INVALID_PATTERN', async () => { const result = await call('glob', { pattern: '[' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_INVALID_PATTERN' }) + expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_INVALID_PATTERN' } }) }) }) @@ -139,13 +139,13 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () it('classifies a real rg regex error as SEARCH_INVALID_PATTERN', async () => { const result = await call('grep', { pattern: '(unclosed' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'SEARCH_INVALID_PATTERN' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_INVALID_PATTERN' } }) }) it('classifies a missing target as SEARCH_FAILED', async () => { const result = await call('grep', { pattern: 'x', path: 'no-such-dir' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } }) }) }) @@ -176,14 +176,14 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () signal: controller.signal, }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' }) + expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_ABORTED' } }) }) it('an unusable session cwd (spawn failure) is SEARCH_FAILED', async () => { const gone = join(dir, 'deleted-session-dir') const result = await call('glob', { pattern: '*' }, { session: { header: { id: 'session-int', cwd: gone } } }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_FAILED' }) + expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } }) expect(text(result)).toContain('could not start') }) }) diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index c11b10556a..146a0e1166 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -14,7 +14,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' +import ToolRegistry, { type ToolExecutionToken } from '@deepseek-ai/dsh-tools' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill' @@ -145,13 +145,19 @@ async function expectSetupRejects(options: SetupOptions, message: RegExp): Promi const agent = (cwd?: string) => ({ session: { header: { id: 'session-1', ...cwd !== undefined ? { cwd } : {} } } }) let callCounter = 0 -function call(ctx: Context, name: string, args: unknown, options: { agent?: object; signal?: AbortSignal } = {}) { +function call( + ctx: Context, + name: string, + args: unknown, + options: { agent?: object; signal?: AbortSignal; parent?: ToolExecutionToken } = {}, +) { return ctx.tools.execute({ callId: CallId(`call-${++callCounter}`), name, arguments: args, ...options.agent ? { agent: options.agent as never } : {}, ...options.signal ? { signal: options.signal } : {}, + ...options.parent ? { parent: options.parent } : {}, }) } @@ -310,7 +316,7 @@ describe('workdir derivation and signal forwarding', () => { const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal }) expect(bash.specs[0]?.signal).toBe(controller.signal) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' }) + expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_ABORTED' } }) expect(text(result)).toContain('aborted') }) @@ -319,7 +325,7 @@ describe('workdir derivation and signal forwarding', () => { bash.handler = () => runResult('', { timedOut: true, timeoutMs: 1234, exitCode: null, signal: 'SIGTERM' }) const result = await call(ctx, 'glob', { pattern: '*' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'SEARCH_ABORTED' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_ABORTED' } }) expect(text(result)).toContain('timed out after 1234ms') }) @@ -332,7 +338,7 @@ describe('workdir derivation and signal forwarding', () => { bash.handler = () => { throw new Error('aborted before spawn') } const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' }) + expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_ABORTED' } }) }) it('translates a run() rejection without an abort (unusable workdir) into SEARCH_FAILED', async () => { @@ -340,7 +346,7 @@ describe('workdir derivation and signal forwarding', () => { bash.handler = () => { throw new Error('spawn bash ENOENT') } const result = await call(ctx, 'glob', { pattern: '*' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_FAILED' }) + expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } }) expect(text(result)).toContain('could not start') }) }) @@ -361,7 +367,7 @@ describe('exit semantics and failure classification', () => { const { ctx, bash } = await setup() bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: regex parse error:\n (\nerror: unclosed group', truncated: false } }) const result = await call(ctx, 'grep', { pattern: '(' }) - expect(result.error).toMatchObject({ code: 'SEARCH_INVALID_PATTERN' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_INVALID_PATTERN' } }) expect(text(result)).toContain('regex parse error') }) @@ -369,14 +375,14 @@ describe('exit semantics and failure classification', () => { const { ctx, bash } = await setup() bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: error parsing glob \'[\': unclosed character class', truncated: false } }) const result = await call(ctx, 'glob', { pattern: '[' }) - expect(result.error).toMatchObject({ code: 'SEARCH_INVALID_PATTERN' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_INVALID_PATTERN' } }) }) it('a missing rg binary classifies as SEARCH_FAILED naming ripgrep', async () => { const { ctx, bash } = await setup() bash.handler = () => runResult('', { exitCode: 127, stderr: { text: 'bash: line 1: rg: command not found', truncated: false } }) const result = await call(ctx, 'glob', { pattern: '*' }) - expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } }) expect(text(result)).toContain('requires ripgrep (rg)') // The same classification holds from either evidence alone: the 127 exit // with silent stderr, or a shell's command-not-found text on another exit. @@ -390,7 +396,7 @@ describe('exit semantics and failure classification', () => { const { ctx, bash } = await setup() bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: missing.dir: IO error: no such file or directory', truncated: false } }) const result = await call(ctx, 'grep', { pattern: 'x', path: 'missing.dir' }) - expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } }) expect(text(result)).toContain('IO error') }) @@ -398,7 +404,7 @@ describe('exit semantics and failure classification', () => { const { ctx, bash } = await setup() bash.handler = () => runResult('', { exitCode: 3 }) const result = await call(ctx, 'glob', { pattern: '*' }) - expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } }) expect(text(result)).toContain('exit 3') }) @@ -416,7 +422,7 @@ describe('exit semantics and failure classification', () => { const { ctx, bash } = await setup() bash.handler = () => runResult('', { exitCode: null, signal: 'SIGKILL' }) const result = await call(ctx, 'grep', { pattern: 'x' }) - expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } }) expect(text(result)).toContain('SIGKILL') }) @@ -424,7 +430,7 @@ describe('exit semantics and failure classification', () => { const { ctx, bash } = await setup() bash.handler = () => runResult('', { exitCode: null, signal: null }) const result = await call(ctx, 'glob', { pattern: '*' }) - expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } }) }) }) @@ -442,7 +448,7 @@ describe('raw output acquisition', () => { const { ctx, bash } = await setup({ config: { rawOutputMaxBytes: 16 } }) bash.handler = () => runResult('', { stdout: { text: 'x', truncated: true, spillPath: '/does/not/get-read' } }) const result = await call(ctx, 'glob', { pattern: '*' }) - expect(result.error).toMatchObject({ code: 'SEARCH_RAW_OUTPUT_OVERFLOW' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_RAW_OUTPUT_OVERFLOW' } }) expect(text(result)).toContain('narrow pattern, path, or include') }) @@ -453,7 +459,7 @@ describe('raw output acquisition', () => { const { ctx, bash } = await setup({ config: { rawOutputMaxBytes: 16 } }) bash.handler = () => runResult(`${'x'.repeat(64)}\n`) const result = await call(ctx, 'grep', { pattern: 'x' }) - expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_RAW_OUTPUT_OVERFLOW' }) + expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_RAW_OUTPUT_OVERFLOW' } }) expect(text(result)).toContain('narrow pattern, path, or include') }) @@ -461,7 +467,7 @@ describe('raw output acquisition', () => { const { ctx, bash } = await setup() bash.handler = () => runResult('', { stdout: { text: 'partial', truncated: true } }) const result = await call(ctx, 'grep', { pattern: 'x' }) - expect(result.error).toMatchObject({ code: 'SEARCH_RAW_OUTPUT_OVERFLOW' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_RAW_OUTPUT_OVERFLOW' } }) }) }) @@ -470,6 +476,8 @@ describe('glob results', () => { const { ctx, bash } = await setup() bash.handler = () => runResult('/sessions/s1/src/a.ts\n/elsewhere/b.ts\nrel/c.ts\n') const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/sessions/s1') }) + if (result.isError) throw new Error('expected glob success') + expect(result.value).toEqual({ paths: ['src/a.ts', '/elsewhere/b.ts', 'rel/c.ts'] }) expect(text(result)).toBe('src/a.ts\n/elsewhere/b.ts\nrel/c.ts') }) @@ -489,9 +497,15 @@ describe('glob results', () => { it('caps at globMaxResults and saves the FULL sorted list through spillStore', async () => { const { ctx, bash, spill } = await setup({ config: { globMaxResults: 2 }, spill: true }) + ctx.on('tools/post-execute', async () => ({ + kind: 'accept', + additionalContexts: [{ content: [{ type: 'text', text: 'glob context' }], source: { kind: 'plugin', plugin: 'test' } }], + })) bash.handler = () => runResult('a.ts\nb.ts\nc.ts\nd.ts\n') const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') }) expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected glob success') + expect(result.value).toEqual({ paths: ['a.ts', 'b.ts', 'c.ts', 'd.ts'] }) expect(text(result)).toBe('a.ts\nb.ts\n\n(Showing 2 of 4 paths. Full sorted result stored at: /spill/glob-results.txt. Use the fake retrieval hint.)') expect(spill?.saves).toHaveLength(1) expect(spill?.saves[0]).toMatchObject({ @@ -501,6 +515,7 @@ describe('glob results', () => { content: 'a.ts\nb.ts\nc.ts\nd.ts', }) expect(spill?.saves[0]?.source.callId).toBeDefined() + expect(result.additionalContexts?.[0]?.content).toEqual([{ type: 'text', text: 'glob context' }]) }) it('does not create a spill file when the result fits inline', async () => { @@ -511,6 +526,36 @@ describe('glob results', () => { expect(spill?.saves).toHaveLength(0) }) + it('preserves a downstream canonical value replacement instead of spilling the old value', async () => { + const { ctx, bash, spill } = await setup({ config: { globMaxResults: 1 }, spill: true }) + ctx.on('tools/post-execute', async () => ({ + kind: 'accept' as const, + value: { paths: ['replacement-a.ts', 'replacement-b.ts'] }, + })) + bash.handler = () => runResult('old-a.ts\nold-b.ts\n') + + const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') }) + + if (result.isError) throw new Error('expected glob replacement success') + expect(result.value).toEqual({ paths: ['replacement-a.ts', 'replacement-b.ts'] }) + expect(text(result)).toContain('replacement-a.ts') + expect(text(result)).not.toContain('old-a.ts') + expect(spill?.saves).toHaveLength(0) + }) + + it('keeps the full nested Code value without creating a surface spill', async () => { + const { ctx, bash, spill } = await setup({ config: { globMaxResults: 2 }, spill: true }) + bash.handler = () => runResult('a.ts\nb.ts\nc.ts\nd.ts\n') + const result = await call(ctx, 'glob', { pattern: '*.ts' }, { + agent: agent('/w'), + parent: Symbol('run_code') as ToolExecutionToken, + }) + if (result.isError) throw new Error('expected glob success') + expect(result.value).toEqual({ paths: ['a.ts', 'b.ts', 'c.ts', 'd.ts'] }) + expect(text(result)).toBe('a.ts\nb.ts\n\n(Showing 2 of 4 paths. The complete result could not be saved; narrow pattern or path to see more.)') + expect(spill?.saves).toHaveLength(0) + }) + it.each([ ['no spill backend loaded', { fail: false, spill: false, ownerless: false }], ['saveText fails', { fail: true, spill: true, ownerless: false }], @@ -539,6 +584,14 @@ describe('grep results', () => { ].join('\n')) const result = await call(ctx, 'grep', { pattern: 'const' }) expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected grep success') + expect(result.value).toEqual({ + matches: [ + { path: 'a.ts', lineNumber: 3, line: 'const x = 1' }, + { path: 'a.ts', lineNumber: 9, line: 'const y = 2' }, + { path: 'b.ts', lineNumber: 1, line: 'const z = 3' }, + ], + }) expect(text(result)).toBe('Found 3 matches\n\na.ts\nLine 3: const x = 1\nLine 9: const y = 2\n\nb.ts\nLine 1: const z = 3') }) @@ -561,6 +614,8 @@ describe('grep results', () => { // Use a multibyte straddle instead: 'aé' repeated — cut at 7 bytes: a(1)é(2)a(1)é(2)=6 +a(1)=7 → next é straddles: trimmed. bash.handler = () => runResult(`${matchLine('a.txt', 1, 'aéaéaéaé')}\n`) const result = await call(ctx, 'grep', { pattern: 'a' }) + if (result.isError) throw new Error('expected grep success') + expect(result.value).toEqual({ matches: [{ path: 'a.txt', lineNumber: 1, line: 'aéaéaéaé' }] }) expect(text(result)).toContain('Line 1: aéaéa (line truncated)') }) @@ -578,6 +633,10 @@ describe('grep results', () => { it('caps at grepMaxMatches and spills the full formatted match list', async () => { const { ctx, bash, spill } = await setup({ config: { grepMaxMatches: 2 }, spill: true }) + ctx.on('tools/post-execute', async () => ({ + kind: 'accept', + additionalContexts: [{ content: [{ type: 'text', text: 'grep context' }], source: { kind: 'plugin', plugin: 'test' } }], + })) bash.handler = () => runResult([ matchLine('a.ts', 1, 'one'), matchLine('a.ts', 2, 'two'), @@ -585,12 +644,66 @@ describe('grep results', () => { '', ].join('\n')) const result = await call(ctx, 'grep', { pattern: 'e' }, { agent: agent('/w') }) + if (result.isError) throw new Error('expected grep success') + expect(result.value).toEqual({ + matches: [ + { path: 'a.ts', lineNumber: 1, line: 'one' }, + { path: 'a.ts', lineNumber: 2, line: 'two' }, + { path: 'b.ts', lineNumber: 3, line: 'three' }, + ], + }) expect(text(result)).toBe('Found 2 of 3 matches\n\na.ts\nLine 1: one\nLine 2: two\n\n(Full grep result stored at: /spill/grep-results.txt. Use the fake retrieval hint.)') expect(spill?.saves[0]).toMatchObject({ source: { toolName: 'grep', label: 'result' }, suggestedName: 'grep-results.txt', content: 'Found 3 matches\n\na.ts\nLine 1: one\nLine 2: two\n\nb.ts\nLine 3: three', }) + expect(result.additionalContexts?.[0]?.content).toEqual([{ type: 'text', text: 'grep context' }]) + }) + + it('preserves a downstream canonical value replacement instead of spilling the old matches', async () => { + const { ctx, bash, spill } = await setup({ config: { grepMaxMatches: 1 }, spill: true }) + ctx.on('tools/post-execute', async () => ({ + kind: 'accept' as const, + value: { + matches: [ + { path: 'replacement.ts', lineNumber: 7, line: 'first' }, + { path: 'replacement.ts', lineNumber: 8, line: 'second' }, + ], + }, + })) + bash.handler = () => runResult(`${matchLine('old.ts', 1, 'old')}\n`) + + const result = await call(ctx, 'grep', { pattern: 'old' }, { agent: agent('/w') }) + + if (result.isError) throw new Error('expected grep replacement success') + expect(result.value).toEqual({ + matches: [ + { path: 'replacement.ts', lineNumber: 7, line: 'first' }, + { path: 'replacement.ts', lineNumber: 8, line: 'second' }, + ], + }) + expect(text(result)).toContain('replacement.ts') + expect(text(result)).not.toContain('old.ts') + expect(spill?.saves).toHaveLength(0) + }) + + it('keeps every nested Code match in the value without creating a surface spill', async () => { + const { ctx, bash, spill } = await setup({ config: { grepMaxMatches: 1 }, spill: true }) + bash.handler = () => runResult(`${matchLine('a.ts', 1, 'one')}\n${matchLine('b.ts', 2, 'two')}\n`) + const result = await call(ctx, 'grep', { pattern: 'o' }, { + agent: agent('/w'), + parent: Symbol('run_code') as ToolExecutionToken, + }) + if (result.isError) throw new Error('expected grep success') + expect(result.value).toEqual({ + matches: [ + { path: 'a.ts', lineNumber: 1, line: 'one' }, + { path: 'b.ts', lineNumber: 2, line: 'two' }, + ], + }) + expect(text(result)).toBe('Found 1 of 2 matches\n\na.ts\nLine 1: one\n\n(The complete result could not be saved; narrow pattern, path, or include to see more.)') + expect(spill?.saves).toHaveLength(0) }) it('reports the unsaved remainder when capped with no spill backend', async () => { @@ -633,7 +746,7 @@ describe('rg --json transport failures (SEARCH_FAILED)', () => { bash.handler = () => runResult(`${line}\n`) const result = await call(ctx, 'grep', { pattern: 'x' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_FAILED' }) + expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } }) }) }) diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index 6dd22d384f..a99316c181 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -32,6 +32,8 @@ All keys are optional; the defaults are the shipped read caps. Field names are snake_case to match Claude Code and existing harness tool schemas. +Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. Native renderers preserve the line-numbered read and mutation acknowledgements below. Write/edit derive replayable diff-card metadata from these values; the values themselves are execution-local and are not added to `tool/result`. + ## The tool is the executor; policy is an event gate The tools do **not** inject a policy service or inspect any cache. Each tool resolves the path via `ctx.fs.resolve(path, { cwd, signal })` — passing the calling agent's session cwd (`exec.agent.session.header.cwd`) so a relative path resolves against the session's workspace, matching `dsh-tool-bash`, and forwarding tool cancellation through resolution (see [the per-session cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md)) — then: diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index 46655f80b7..e2895c5cf4 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -8,10 +8,9 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-tools' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' -import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts' +import { computeHunkDiffs, diffsFromMeta } from './diff.ts' import { sessionResolveOptions } from './session-cwd.ts' import type { FsSandboxSurface } from './sandbox.ts' @@ -90,7 +89,26 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void { replace_all: { type: 'boolean', description: 'Replace all matches. Defaults to false; when false, old_string must appear exactly once.' }, ...sandbox.escalationModes.length > 0 ? sandbox.schemaFields() : {}, }, - async execute(args: EditToolArgs, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> { + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + path: { type: 'string', required: true }, + before: { type: 'string', required: true }, + after: { type: 'string', required: true }, + }, + }, + render: (args, value) => [{ + type: 'text', + text: formatEditOutput(value.path, args.replace_all ?? false), + }], + presentationMeta: (args, value) => ({ + diffs: computeHunkDiffs(args.file_path, value.before, value.after) + .map(({ path, oldText, newText }) => ({ path, oldText, newText })), + }), + }, + async execute(args: EditToolArgs, exec) { const input = parseEditArgs(args) // Resolve the per-call sandbox mode (escalation grant > session override // > backend default) BEFORE anything executes. @@ -115,11 +133,10 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void { } // Record the observed version (a no-op when no policy plugin listens). ctx.emit('fs/observed', target, outcome.version, exec) - // An edit necessarily changes content, so result metadata carries at least one applied hunk. - const diffs = computeHunkDiffs(input.filePath, outcome.before, outcome.after) return { - content: [{ type: 'text', text: formatEditOutput(target.displayPath, input.replaceAll) }], - meta: { diffs }, + path: target.displayPath, + before: outcome.before, + after: outcome.after, } }, // Pure display: a diff card of the literal replacement (old_string → new_string), derived diff --git a/packages/fs/tool-fs/src/read-render.ts b/packages/fs/tool-fs/src/read-render.ts index 943ff98f61..de9e530a13 100644 --- a/packages/fs/tool-fs/src/read-render.ts +++ b/packages/fs/tool-fs/src/read-render.ts @@ -77,6 +77,7 @@ function lineByteSize(line: string, currentLineCount: number): number { function consumeLine(acc: WindowAccumulator, rawLine: string, request: ReadWindow): void { acc.totalLines += 1 + if (acc.done) return if (acc.totalLines < request.offset || acc.lines.length >= request.limit) return const text = truncateLine(rawLine, request.maxLineLength) @@ -137,7 +138,6 @@ export async function buildWindow( appendToLineBuffer(chunk.slice(startPos, newlinePos)) flushLine() startPos = newlinePos + 1 - if (acc.done) return finish(acc, request, displayPath) } appendToLineBuffer(chunk.slice(startPos)) } diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index a19b073514..ea709f1172 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -7,12 +7,10 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView } from '@deepseek-ai/dsh-tools' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { FsError } from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' import { buildWindow, formatReadOutput } from './read-render.ts' -import type { FileReadOutcome } from './read-render.ts' import { sessionResolveOptions } from './session-cwd.ts' /** Default and maximum number of lines returned by one `read` call (the `readLimit` config). */ @@ -84,9 +82,46 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void { offset: { type: 'number', description: '1-based first line to return. Defaults to 1.' }, limit: { type: 'number', description: `Maximum number of lines to return. Defaults to ${caps.limit}.` }, }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + path: { type: 'string', required: true }, + offset: { type: 'integer', required: true }, + lines: { + type: 'array', + required: true, + items: { + type: 'object', + additionalProperties: false, + properties: { + number: { type: 'integer', required: true }, + text: { type: 'string', required: true }, + }, + }, + }, + totalLines: { type: 'integer', required: true }, + }, + }, + render: (args, value) => { + const input = parseReadArgs(args, caps.limit) + const endLine = value.lines.at(-1)?.number ?? Math.max(0, value.offset - 1) + const truncatedByBytes = value.lines.length < input.limit && endLine < value.totalLines + return [{ + type: 'text', + text: formatReadOutput(value.path, { + offset: value.offset, + lines: value.lines, + totalLines: value.totalLines, + ...truncatedByBytes ? { truncatedByBytes: true } : {}, + }), + }] + }, + }, // Observation races fail closed because guarded mutations re-check the version in-lock. isConcurrencySafe: () => true, - async execute(args, exec): Promise { + async execute(args, exec) { const input = parseReadArgs(args, caps.limit) const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec)) @@ -107,17 +142,17 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void { target.displayPath, ) - const outcome: FileReadOutcome = { + const outcome = { + path: target.displayPath, offset: input.offset, lines: window.lines, totalLines: window.totalLines, - ...window.truncatedByBytes ? { truncatedByBytes: true } : {}, } // Record the observed version (a no-op when no policy plugin listens). The // read already succeeded; an fs/observed listener is contractually a // synchronous, side-effect-only recorder. ctx.emit('fs/observed', target, info.version, exec) - return [{ type: 'text', text: formatReadOutput(target.displayPath, outcome) }] + return outcome }, // Pure display: a generic card titled by the file with the read window appended (`Read // foo.txt (5 - 8)`), `read` kind (icon), and a follow-along location whose line is the diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index 3f23e9b5ea..b541c1c2e0 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -8,11 +8,10 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-tools' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { FsWriteOutcome } from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' -import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts' +import { computeHunkDiffs, diffsFromMeta } from './diff.ts' import { sessionResolveOptions } from './session-cwd.ts' import type { FsSandboxSurface } from './sandbox.ts' @@ -33,7 +32,7 @@ export function parseWriteArgs(args: { file_path: string; content: string }): { * @param outcome - the write outcome; its `operation` selects the Created/Updated wording. * @returns the model-facing confirmation envelope (no file content is echoed back). */ -export function formatWriteOutput(displayPath: string, outcome: FsWriteOutcome): string { +export function formatWriteOutput(displayPath: string, outcome: Pick): string { const verb = outcome.operation === 'create' ? 'Created' : 'Updated' return `${displayPath} file @@ -74,7 +73,32 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void { content: { type: 'string', required: true, description: 'Full UTF-8 text content to write.' }, ...sandbox.escalationModes.length > 0 ? sandbox.schemaFields() : {}, }, - async execute(args: WriteToolArgs, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> { + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + path: { type: 'string', required: true }, + operation: { type: 'string', required: true, enum: ['create', 'update'] }, + before: { + required: true, + oneOf: [ + { type: 'string' }, + { type: 'null' }, + ], + }, + after: { type: 'string', required: true }, + }, + }, + render: (_args, value) => [{ type: 'text', text: formatWriteOutput(value.path, value) }], + presentationMeta: (args, value) => ({ + diffs: value.before === null + ? [] + : computeHunkDiffs(args.file_path, value.before, value.after) + .map(({ path, oldText, newText }) => ({ path, oldText, newText })), + }), + }, + async execute(args: WriteToolArgs, exec) { const input = parseWriteArgs(args) // Resolve the per-call sandbox mode (escalation grant > session override // > backend default) BEFORE anything executes; an escalating call @@ -94,12 +118,11 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void { } // Record the observed version (a no-op when no policy plugin listens). ctx.emit('fs/observed', target, outcome.version, exec) - // Overwrites carry applied hunks. Creates have no prior text, so result presentation uses - // the args-derived whole-file diff instead. - const diffs = outcome.before !== null ? computeHunkDiffs(input.filePath, outcome.before, outcome.after) : [] return { - content: [{ type: 'text', text: formatWriteOutput(target.displayPath, outcome) }], - ...diffs.length > 0 ? { meta: { diffs } } : {}, + path: target.displayPath, + operation: outcome.operation, + before: outcome.before, + after: outcome.after, } }, // Pure display: a diff card (an editor renders write as a new-file / full- replace diff). diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index 6a9e6f3568..05056cada2 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -67,7 +67,7 @@ describe('default deployment (with dsh-fs-policy)', () => { await writeFile(join(dir, 'a.txt'), 'original') const result = await call('write', { file_path: 'a.txt', content: 'clobber' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' }) + expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } }) expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('original') }) @@ -85,7 +85,7 @@ describe('default deployment (with dsh-fs-policy)', () => { await writeFile(join(dir, 'a.txt'), 'changed-externally') // out-of-band change const result = await call('write', { file_path: 'a.txt', content: 'replaced' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' }) + expect(result.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) }) }) @@ -102,7 +102,7 @@ describe('default deployment (with dsh-fs-policy)', () => { await writeFile(join(dir, 'bin'), Buffer.from([0x00, 0x01, 0x02])) const result = await call('read', { file_path: 'bin' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_NOT_TEXT' }) + expect(result.error).toMatchObject({ info: { code: 'FS_NOT_TEXT' } }) }) it('paginates a multi-line file with offset/limit', async () => { @@ -127,7 +127,7 @@ describe('default deployment (with dsh-fs-policy)', () => { await writeFile(join(dir, 'a.txt'), 'hello world') const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' }) + expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } }) expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world') }) @@ -151,7 +151,7 @@ describe('default deployment (with dsh-fs-policy)', () => { await writeFile(join(dir, 'a.txt'), 'goodbye') // out-of-band change removes 'world' const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' }) + expect(result.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) }) it('rejects an ambiguous match without replace_all', async () => { @@ -159,7 +159,7 @@ describe('default deployment (with dsh-fs-policy)', () => { await call('read', { file_path: 'a.txt' }) const result = await call('edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_AMBIGUOUS_EDIT' }) + expect(result.error).toMatchObject({ info: { code: 'FS_AMBIGUOUS_EDIT' } }) expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('a a a') }) @@ -187,7 +187,7 @@ describe('default deployment (with dsh-fs-policy)', () => { // The model-facing edit still rejects: the read did not emit fs/observed. const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' }) + expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } }) }) }) @@ -260,14 +260,14 @@ describe('bare provider (no dsh-fs-policy)', () => { it('edit of a MISSING target reports FS_STALE_VERSION even on the unguarded path', async () => { const result = await call('edit', { file_path: 'missing.txt', old_string: 'a', new_string: 'b' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' }) + expect(result.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) }) it('edit still enforces literal-match codes (FS_EDIT_NOT_FOUND), unrelated to freshness', async () => { await writeFile(join(dir, 'a.txt'), 'hello world') const result = await call('edit', { file_path: 'a.txt', old_string: 'absent', new_string: 'x' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_EDIT_NOT_FOUND' }) + expect(result.error).toMatchObject({ info: { code: 'FS_EDIT_NOT_FOUND' } }) }) it('neither write nor edit stats in the tool on the bare path', async () => { @@ -350,11 +350,11 @@ describe('signal, concurrency, and the fs/observed contract', () => { await writeFile(join(dir, 'a.txt'), 'hello') const read = await callSig(AbortSignal.abort(), 'read', { file_path: 'a.txt' }) expect(read.isError).toBe(true) - expect(read.error).toMatchObject({ code: 'FS_ABORTED' }) + expect(read.error).toMatchObject({ info: { code: 'FS_ABORTED' } }) const write = await callSig(AbortSignal.abort(), 'write', { file_path: 'new.txt', content: 'x' }) expect(write.isError).toBe(true) - expect(write.error).toMatchObject({ code: 'FS_ABORTED' }) + expect(write.error).toMatchObject({ info: { code: 'FS_ABORTED' } }) await expect(readFile(join(dir, 'new.txt'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) // Read first (un-aborted, SAME session owner) so the edit clears the @@ -363,7 +363,7 @@ describe('signal, concurrency, and the fs/observed contract', () => { expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false) const edit = await callSig(AbortSignal.abort(), 'edit', { file_path: 'a.txt', old_string: 'hello', new_string: 'bye' }) expect(edit.isError).toBe(true) - expect(edit.error).toMatchObject({ code: 'FS_ABORTED' }) + expect(edit.error).toMatchObject({ info: { code: 'FS_ABORTED' } }) expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello') // unchanged }) @@ -378,7 +378,7 @@ describe('signal, concurrency, and the fs/observed contract', () => { ]) const errors = [one, two].filter(r => r.isError) expect(errors).toHaveLength(1) - expect(errors[0]?.error).toMatchObject({ code: 'FS_STALE_VERSION' }) + expect(errors[0]?.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) // The world is consistent: exactly one edit landed. const onDisk = await readFile(join(dir, 'a.txt'), 'utf8') expect(onDisk === 'ONE value here' || onDisk === 'base TWO here').toBe(true) @@ -407,7 +407,7 @@ describe('signal, concurrency, and the fs/observed contract', () => { new_string: 'edited', }) expect(edit.isError).toBe(true) - expect(edit.error).toMatchObject({ code: 'FS_STALE_VERSION' }) + expect(edit.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('newer current content\n') }) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 4d80061584..b94bcb9486 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -161,6 +161,13 @@ describe('read tool', () => { fs.files.set('key:a.txt', 'hello\nworld') const result = await call(ctx, 'read', { file_path: 'a.txt' }) expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected read success') + expect(result.value).toEqual({ + path: '/abs/a.txt', + offset: 1, + lines: [{ number: 1, text: 'hello' }, { number: 2, text: 'world' }], + totalLines: 2, + }) expect(text(result)).toBe(`/abs/a.txt file @@ -171,6 +178,15 @@ describe('read tool', () => { `) }) + it('returns an explicit empty canonical line window for an empty file', async () => { + const { ctx, fs } = await setup() + fs.files.set('key:empty.txt', '') + const result = await call(ctx, 'read', { file_path: 'empty.txt' }) + if (result.isError) throw new Error('expected empty read success') + expect(result.value).toEqual({ path: '/abs/empty.txt', offset: 1, lines: [], totalLines: 0 }) + expect(text(result)).toContain('(End of file - total 0 lines)') + }) + it('rejects a non-positive offset via arg validation', async () => { const { ctx } = await setup() const result = await call(ctx, 'read', { file_path: 'a.txt', offset: 0 }) @@ -226,7 +242,7 @@ describe('read tool', () => { const { ctx } = await setup() const result = await call(ctx, 'read', { file_path: 'missing.txt' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_NOT_FOUND' }) + expect(result.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } }) }) it('rejects a non-regular target', async () => { @@ -235,7 +251,7 @@ describe('read tool', () => { fs.stat = async () => ({ version: FsVersion('v1'), type: 'directory' }) const result = await call(ctx, 'read', { file_path: 'd' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) + expect(result.error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } }) }) it('streams a large file (size at/above the cap) instead of reading whole', async () => { @@ -301,6 +317,8 @@ describe('write tool', () => { const { ctx, fs } = await setup() const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' }, { session: { header: {} } }) expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected write success') + expect(result.value).toEqual({ path: '/abs/a.txt', operation: 'create', before: null, after: 'hi' }) expect(text(result)).toContain('Created file') expect(fs.writeIntents).toEqual([{ kind: 'createIfAbsent' }]) }) @@ -317,7 +335,7 @@ describe('write tool', () => { fs.rejectWith = new FsError('blocked', 'FS_STALE_VERSION') const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ name: 'FsError', code: 'FS_STALE_VERSION' }) + expect(result.error).toMatchObject({ info: { name: 'FsError', code: 'FS_STALE_VERSION' } }) }) }) @@ -328,6 +346,8 @@ describe('edit tool', () => { fs.files.set('key:a.txt', 'a') await call(ctx, 'read', { file_path: 'a.txt' }, { session }) const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session }) + if (result.isError) throw new Error('expected edit success') + expect(result.value).toEqual({ path: '/abs/a.txt', before: 'a', after: 'b' }) expect(text(result)).toBe('The file /abs/a.txt has been updated successfully.') }) @@ -366,7 +386,7 @@ describe('edit tool', () => { fs.files.set('key:a.txt', 'hello') const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session: { header: {} } }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' }) + expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } }) }) }) @@ -464,27 +484,27 @@ describe('result-time contextual diff (meta + presentResult)', () => { expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }] }) }) - it('write CREATE: no before-version → no meta, but presentResult still renders a whole-file diff card', async () => { - // A create has no prior content (no `meta`), yet the completed card must be a `diff` — an + it('write CREATE: an empty applied-diff projection still falls back to the whole-file diff card', async () => { + // A create has no prior content, yet the completed card must be a `diff` — an // ACP tool_call_update.content REPLACES the call's content, so a non-diff result would // clobber the pending new-file diff. const { ctx } = await setup() const session = { header: {} } const result = await call(ctx, 'write', { file_path: 'new.txt', content: 'fresh\n' }, { session }) expect(result.isError).toBe(false) - expect(result.meta).toBeUndefined() + expect(result.meta).toEqual({ diffs: [] }) const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'new.txt', content: 'fresh\n' }, result) expect(view).toEqual({ card: 'diff', title: 'Write new.txt', diffs: [{ path: 'new.txt', oldText: null, newText: 'fresh\n' }] }) }) - it('write OVERWRITE with identical content: a before exists but yields no hunk → no meta, presentResult falls back to a whole-file diff', async () => { + it('write OVERWRITE with identical content: an empty applied-diff projection falls back to a whole-file diff', async () => { const { ctx, fs } = await setup() const session = { header: {} } fs.files.set('key:a.txt', 'same\n') await call(ctx, 'read', { file_path: 'a.txt' }, { session }) const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'same\n' }, { session }) expect(result.isError).toBe(false) - expect(result.meta).toBeUndefined() + expect(result.meta).toEqual({ diffs: [] }) const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'same\n' }, result) expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: null, newText: 'same\n' }] }) }) diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index 6b900e2a05..3f286f8ec3 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -10,6 +10,8 @@ The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations. +All three canonical values match the compact JSON already rendered to Native callers: `{ goal: null }` or `{ goal: { id, revision, objective, phase, roundsStarted, maxGoalRounds, blockedReason? }, activation }`. Programmatic consumers therefore receive the same domain structure without parsing the rendered JSON. + An autonomous goal round that successfully reports `complete` or `blocked` contributes the existing terminal `agent/turn-stop` decision for that physical turn. Direct-human mutations never contribute this stop: the assistant may acknowledge the change and concurrent human steering remains available to the loop. ## Authority diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 075264f93e..009a00376f 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -54,6 +54,62 @@ const GET_DESCRIPTION = + 'continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. ' + 'Call this before updating a goal.' +/** Canonical goal-tool output, matching the existing compact Native JSON. */ +type GoalToolValue = + | { goal: null } + | { + goal: { + id: string + revision: number + objective: string + phase: GoalView['phase'] + roundsStarted: number + maxGoalRounds: number + blockedReason?: { code: string; message: string } + } + activation: GoalView['activation'] + } + +const GOAL_VALUE_SCHEMA = { + oneOf: [ + { + type: 'object', + additionalProperties: false, + properties: { + goal: { type: 'null', required: true }, + }, + }, + { + type: 'object', + additionalProperties: false, + properties: { + goal: { + type: 'object', + additionalProperties: false, + required: true, + properties: { + id: { type: 'string', required: true }, + revision: { type: 'integer', required: true }, + objective: { type: 'string', required: true }, + phase: { type: 'string', required: true, enum: ['active', 'paused', 'blocked', 'complete'] }, + roundsStarted: { type: 'integer', required: true }, + maxGoalRounds: { type: 'integer', required: true }, + blockedReason: { + type: 'object', + additionalProperties: false, + properties: { + code: { type: 'string', required: true }, + message: { type: 'string', required: true }, + }, + }, + }, + }, + activation: { type: 'string', required: true, enum: ['armed', 'disarmed'] }, + }, + }, + ], +} as const + /** Render policy guidance with its deployment-selected blocked threshold. */ function guidance(blockedAfter: number): string { return 'Use goal tools for one long-running completion objective in the current session. ' @@ -89,9 +145,9 @@ function goalRef(goalId: string, revision: number): GoalRef { } /** Stable compact model result; activation is an observation, not replay state. */ -function renderGoal(goal: GoalView | undefined): string { - if (goal === undefined) return JSON.stringify({ goal: null }) - return JSON.stringify({ +function goalValue(goal: GoalView | undefined): GoalToolValue { + if (goal === undefined) return { goal: null } + return { goal: { id: goal.id, revision: goal.revision, @@ -99,10 +155,18 @@ function renderGoal(goal: GoalView | undefined): string { phase: goal.phase, roundsStarted: goal.roundsStarted, maxGoalRounds: goal.maxGoalRounds, - ...goal.blockedReason === undefined ? {} : { blockedReason: goal.blockedReason }, + ...goal.blockedReason === undefined ? {} : { + blockedReason: { code: goal.blockedReason.code, message: goal.blockedReason.message }, + }, }, activation: goal.activation, - }) + } +} + +/** Reusable canonical output declaration for all three goal controls. */ +const GOAL_OUTPUT = { + schema: GOAL_VALUE_SCHEMA, + render: (_args: unknown, value: GoalToolValue) => [{ type: 'text' as const, text: JSON.stringify(value) }], } /** Generic, args-only pending presentation shared by the goal tools. */ @@ -144,12 +208,10 @@ export function apply(ctx: Context, config: Config): void { name: 'get_goal', description: GET_DESCRIPTION, parameters: {}, + output: GOAL_OUTPUT, execute(_args, exec) { const execution = goalToolExecution(ctx, exec) - return Promise.resolve([{ - type: 'text', - text: renderGoal(ctx.goals.get(execution.agent)), - }]) + return Promise.resolve(goalValue(ctx.goals.get(execution.agent))) }, presentCall: () => present('Read current goal', 'read'), })) @@ -168,6 +230,7 @@ export function apply(ctx: Context, config: Config): void { description: 'Optional positive safe-integer limit on automatic continuation rounds.', }, }, + output: GOAL_OUTPUT, execute(args, exec) { const execution = goalToolExecution(ctx, exec) requireDirectHuman(ctx, execution) @@ -176,7 +239,7 @@ export function apply(ctx: Context, config: Config): void { ...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds }, }) observeMutation(terminalTurns, execution, false) - return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) + return Promise.resolve(goalValue(goal)) }, presentCall: args => present('Create goal', 'other', args.objective), })) @@ -203,6 +266,7 @@ export function apply(ctx: Context, config: Config): void { description: 'Concrete blocking condition; required only with action blocked.', }, }, + output: GOAL_OUTPUT, execute(args, exec) { const execution = goalToolExecution(ctx, exec) const ref = goalRef(args.goal_id, args.revision) @@ -217,10 +281,7 @@ export function apply(ctx: Context, config: Config): void { } const goal = ctx.goals.edit(execution.agent, ref, replacements) observeMutation(terminalTurns, execution, false) - return Promise.resolve([{ - type: 'text', - text: renderGoal(goal), - }]) + return Promise.resolve(goalValue(goal)) } if (args.action === 'pause' || args.action === 'resume') { requireDirectHuman(ctx, execution) @@ -234,7 +295,7 @@ export function apply(ctx: Context, config: Config): void { ? ctx.goals.pause(execution.agent, ref) : ctx.goals.resume(execution.agent, ref) observeMutation(terminalTurns, execution, false) - return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) + return Promise.resolve(goalValue(goal)) } const authority = completionAuthority(ctx, execution) if (args.objective !== undefined || args.max_goal_rounds !== undefined) { @@ -265,7 +326,7 @@ export function apply(ctx: Context, config: Config): void { message: args.blocked_reason as string, }) observeMutation(terminalTurns, execution, authority.kind === 'goal-round') - return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) + return Promise.resolve(goalValue(goal)) }, presentCall: args => present( `${args.action === 'blocked' ? 'Mark' : args.action.charAt(0).toUpperCase() + args.action.slice(1)} goal`, diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 2065bac23e..8acd304bca 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -95,9 +95,12 @@ async function execute( /** Parse the compact JSON returned by a successful goal tool. */ function resultJson(result: ToolExecutionResult): Record { expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected goal tool success') const block = result.content[0] if (block?.type !== 'text') throw new Error('expected text tool result') - return JSON.parse(block.text) as Record + const parsed = JSON.parse(block.text) as Record + expect(result.value).toEqual(parsed) + return parsed } /** Read the returned goal sub-object. */ @@ -193,7 +196,7 @@ describe('goal tool execution authority', () => { it('rejects agentless, driverless, non-human, and live-child creation', async () => { const { ctx, root } = await harness() const agentless = await execute(ctx, 'get_goal', {}) - expect(agentless.error?.code).toBe('GOAL_TOOL_AGENT_REQUIRED') + expect(agentless.error?.info?.code).toBe('GOAL_TOOL_AGENT_REQUIRED') openTurn(root, { kind: 'user' }) const driverless = await ctx.tools.execute({ @@ -202,12 +205,12 @@ describe('goal tool execution authority', () => { arguments: {}, agent: root.agent, }) - expect(driverless.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + expect(driverless.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') closeTurn(root, 1) openTurn(root, { kind: 'plugin', plugin: 'test' }) const nonHuman = await execute(ctx, 'create_goal', { objective: 'forged' }, root.agent) - expect(nonHuman.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') + expect(nonHuman.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') closeTurn(root, 2) const child = stubAgent('goal-tool-child') @@ -215,7 +218,7 @@ describe('goal tool execution authority', () => { ctx.agents.announce(child.agent) openTurn(child, { kind: 'user' }) const childResult = await execute(ctx, 'create_goal', { objective: 'child goal' }, child.agent) - expect(childResult.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') + expect(childResult.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') }) it('rejects stale agent objects and agents outside running status through the executor', async () => { @@ -223,11 +226,11 @@ describe('goal tool execution authority', () => { openTurn(root, { kind: 'user' }) const stale = { ...root.agent } const staleResult = await execute(ctx, 'get_goal', {}, stale, stale) - expect(staleResult.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + expect(staleResult.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') root.setStatus('idle') const idleResult = await execute(ctx, 'get_goal', {}, root.agent) - expect(idleResult.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + expect(idleResult.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') }) it('treats a fork resumed as a runtime root as direct-human authority', async () => { @@ -257,12 +260,12 @@ describe('goal tool execution authority', () => { it('rejects calls before a turn and after its end boundary', async () => { const { ctx, root } = await harness() const before = await execute(ctx, 'get_goal', {}, root.agent) - expect(before.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + expect(before.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') const turn = openTurn(root, { kind: 'user' }) closeTurn(root, turn) const after = await execute(ctx, 'get_goal', {}, root.agent) - expect(after.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + expect(after.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') }) it('rejects terminal reporting without human input or a current goal round', async () => { @@ -271,11 +274,11 @@ describe('goal tool execution authority', () => { const result = await execute(ctx, 'update_goal', { goal_id: 'goal-missing', revision: 1, action: 'complete', }, root.agent) - expect(result.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') + expect(result.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') const malformed = await execute(ctx, 'update_goal', { goal_id: 'goal-missing', revision: 1, action: 'pause', objective: 'probe', }, root.agent) - expect(malformed.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') + expect(malformed.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') }) it('accepts direct human steering in a goal-sourced root turn', async () => { @@ -303,7 +306,7 @@ describe('goal tool execution authority', () => { ctx.agents.register(other.agent) openTurn(other, { kind: 'user' }) const result = await execute(ctx, 'get_goal', {}, other.agent, root.agent) - expect(result.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + expect(result.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') }) }) @@ -374,7 +377,7 @@ describe('goal tool state transitions', () => { const { ctx, root } = await harness() openTurn(root, { kind: 'user' }) const invalidCreate = await execute(ctx, 'create_goal', { objective: ' ' }, root.agent) - expect(invalidCreate.error?.code).toBe('GOAL_INVALID_OBJECTIVE') + expect(invalidCreate.error?.info?.code).toBe('GOAL_INVALID_OBJECTIVE') const created = ctx.goals.create(root.agent, { objective: 'valid' }) const replacement = await execute(ctx, 'update_goal', { goal_id: created.id, @@ -382,26 +385,26 @@ describe('goal tool state transitions', () => { action: 'pause', objective: 'not valid for pause', }, root.agent) - expect(replacement.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + expect(replacement.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') const terminalUpdate = await execute(ctx, 'update_goal', { goal_id: created.id, revision: created.revision, action: 'complete', max_goal_rounds: 2, }, root.agent) - expect(terminalUpdate.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + expect(terminalUpdate.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') const blockedWithoutReason = await execute(ctx, 'update_goal', { goal_id: created.id, revision: created.revision, action: 'blocked', }, root.agent) - expect(blockedWithoutReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + expect(blockedWithoutReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') const blockedWithEmptyReason = await execute(ctx, 'update_goal', { goal_id: created.id, revision: created.revision, action: 'blocked', blocked_reason: ' ', }, root.agent) - expect(blockedWithEmptyReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + expect(blockedWithEmptyReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') const completeWithReason = await execute(ctx, 'update_goal', { goal_id: created.id, revision: created.revision, action: 'complete', blocked_reason: 'Not a blocker.', }, root.agent) - expect(completeWithReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + expect(completeWithReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') const editWithReason = await execute(ctx, 'update_goal', { goal_id: created.id, revision: created.revision, @@ -409,11 +412,11 @@ describe('goal tool state transitions', () => { objective: 'still valid', blocked_reason: 'Not valid for edit.', }, root.agent) - expect(editWithReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + expect(editWithReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') const malformedRef = await execute(ctx, 'update_goal', { goal_id: '', revision: 0, action: 'edit', objective: 'x', }, root.agent) - expect(malformedRef.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + expect(malformedRef.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') }) it('allows exact goal rounds to complete but not edit or pause', async () => { @@ -425,7 +428,7 @@ describe('goal tool state transitions', () => { const edit = await execute(ctx, 'update_goal', { goal_id: created.id, revision: created.revision, action: 'edit', objective: 'forbidden', }, root.agent) - expect(edit.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') + expect(edit.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') const complete = await execute(ctx, 'update_goal', { goal_id: created.id, revision: created.revision, action: 'complete', }, root.agent) @@ -447,7 +450,7 @@ describe('goal tool state transitions', () => { action: 'blocked', blocked_reason: 'The required credential is still unavailable.', }, root.agent) - expect(result.error?.code).toBe('GOAL_TOOL_BLOCK_THRESHOLD') + expect(result.error?.info?.code).toBe('GOAL_TOOL_BLOCK_THRESHOLD') closeTurn(root, turn) } openTurn(root, { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 3 }) diff --git a/packages/guard/repeat-tool-guard/src/index.ts b/packages/guard/repeat-tool-guard/src/index.ts index 0c630686b4..0a4e3b417f 100644 --- a/packages/guard/repeat-tool-guard/src/index.ts +++ b/packages/guard/repeat-tool-guard/src/index.ts @@ -213,8 +213,7 @@ export function apply(ctx: Context, config: Config): void { return { kind: 'block', feedback: downstream.feedback, additionalContexts: prependContext(reminder, downstream.additionalContexts) } } return { - kind: 'accept', - ...downstream.content !== undefined ? { content: downstream.content } : {}, + ...downstream, additionalContexts: prependContext(reminder, downstream.additionalContexts), } }) diff --git a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts index 101c542d5a..54aacc6073 100644 --- a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts +++ b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -24,8 +24,8 @@ async function harness(config: Config = {}): Promise { await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(RepeatToolGuard, config) - ctx.tools.register(defineTool({ name: 'probe', description: 'p', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - ctx.tools.register(defineTool({ name: 'other', description: 'o', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'probe', description: 'p', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'other', description: 'o', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) return ctx } @@ -332,11 +332,11 @@ describe('fold onto the downstream decision', () => { expect(results[1]!.data.content).toEqual([{ type: 'text', text: 'nope' }]) }) - it('preserves a downstream accept content replacement while folding', async () => { + it('preserves a downstream canonical value replacement while folding', async () => { const ctx = await harness({ thresholds: [2] }) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, - content: [{ type: 'text' as const, text: 'replaced' }], + value: [{ type: 'text' as const, text: 'replaced' }], })) const adapter = new MockAdapter([ toolCallResponse('c1', 'probe', { q: 1 }), diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 03ac21fea6..4f672ea416 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -253,8 +253,7 @@ export function apply(ctx: Context, config: Config): void { return { ...downstream, additionalContexts: prependContext(context, downstream.additionalContexts) } } return { - kind: 'accept', - ...downstream.content !== undefined ? { content: downstream.content } : {}, + ...downstream, additionalContexts: prependContext(context, downstream.additionalContexts), } }) diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 65dd29d146..fd2b018a51 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { Context, type Fiber } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -138,7 +138,7 @@ describe('hooks-claude bridge — PreToolUse', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'danger', {}), textResponse('done')]) const ctx = await harness(dir, adapter) let ran = false - ctx.tools.register(defineTool({ name: 'danger', description: 'd', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'danger', description: 'd', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'use danger' }]) await waitForIdle(ctx, agent) @@ -161,7 +161,7 @@ describe('hooks-claude bridge — PreToolUse', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'safe', {}), textResponse('done')]) const ctx = await harness(dir, adapter) let ran = false - ctx.tools.register(defineTool({ name: 'safe', description: 's', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ran ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'safe', description: 's', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ran ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'use safe' }]) await waitForIdle(ctx, agent) @@ -183,7 +183,7 @@ describe('hooks-claude bridge — PostToolUse', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(dir, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'raw output' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'raw output' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -204,7 +204,7 @@ describe('hooks-claude bridge — PostToolUse', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(dir, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -228,7 +228,7 @@ describe('hooks-claude bridge — PostToolUse', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(dir, adapter) let ran = false - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts index 870d369784..ae6347e31d 100644 --- a/packages/hooks/hooks-claude/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { Context } from 'cordis' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -65,7 +65,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'capture.sh', `#!/usr/bin/env bash\ncat > "${cap}"\n`) }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} }) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -95,7 +95,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter, { pluginRoot: d, projectDir: d }) ctx.logger.warn = warn as never - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -111,7 +111,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) ctx.logger.warn = warn as never let sawArgs: unknown - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: { command: { type: 'string' } }, async execute(args) { sawArgs = args; return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: { command: { type: 'string' } }, async execute(args) { sawArgs = args; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -142,7 +142,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const ctx = await harness(path, new MockAdapter([])) let ran = false - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) // Call execute() directly with NO agent — the bridge's no-agent/no-turn path. const { CallId } = await import('@deepseek-ai/dsh-llm') const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {} }) @@ -157,7 +157,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -182,7 +182,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 }) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -262,7 +262,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'x' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'x' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -276,7 +276,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -320,7 +320,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) let ran = false - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -335,7 +335,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -375,7 +375,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) let ran = false - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -390,7 +390,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -409,7 +409,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) let ran = false - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -426,7 +426,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -446,7 +446,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) let ran = false - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -529,16 +529,16 @@ export function defineCoverageCases(group: CoverageGroup): void { expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' }) }) - it('folds the bridge PostToolUse context onto a downstream ACCEPT that replaces content', async () => { + it('folds the bridge PostToolUse context onto a downstream canonical value replacement', async () => { // The bridge hook adds context; a later post-execute listener accepts with a - // content rewrite. Both the rewrite and the bridge context survive. + // canonical replacement. Both the replacement and the bridge context survive. const d = dir() const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'rewritten-result' }] })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: [{ type: 'text' as const, text: 'rewritten-result' }] })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -553,7 +553,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, additionalContexts: [{ @@ -583,7 +583,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) @@ -608,7 +608,7 @@ export function defineCoverageCases(group: CoverageGroup): void { // yields a HookOutput with exitCode undefined → the `exitCode` spread false arm. const bash = ctx.bash bash.run = (() => Promise.reject(new Error('executor down'))) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -656,7 +656,7 @@ export function defineCoverageCases(group: CoverageGroup): void { await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const { SessionId } = await import('@deepseek-ai/dsh-session') const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } }) diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index 75c33e2d92..41529c64d6 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -226,8 +226,7 @@ export function apply(ctx: Context, config: Config): void { return { ...downstream, additionalContexts: prependContext(context, downstream.additionalContexts) } } return { - kind: 'accept', - ...downstream.content !== undefined ? { content: downstream.content } : {}, + ...downstream, additionalContexts: prependContext(context, downstream.additionalContexts), } }) diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 2cb4cb0bc5..9ce955630b 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -75,7 +75,7 @@ describe('hooks-codex bridge', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')]) const ctx = await harness(dir, adapter) let ran = false - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'no' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'no' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'run ls' }]) await waitForIdle(ctx, agent) diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index e02ea52df1..5c8ddb4487 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { Context } from 'cordis' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -61,7 +61,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'capture.sh', `#!/usr/bin/env bash\ncat > "${cap}"\n`) }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} }) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -144,13 +144,13 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro }) if (selected.has('post-tool')) describe('hooks-codex coverage — post-tool and session context mapping', () => { - it('folds the bridge PostToolUse context onto a downstream ACCEPT that replaces content', async () => { + it('folds the bridge PostToolUse context onto a downstream canonical value replacement', async () => { const d = dir() hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'rewritten-result' }] })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: [{ type: 'text' as const, text: 'rewritten-result' }] })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -163,7 +163,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, additionalContexts: [{ @@ -188,7 +188,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) @@ -215,7 +215,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'p.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const r = events(agent).find(e => e.type === 'tool/result') @@ -228,7 +228,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"post-ctx"}}\'\n') }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true) @@ -242,7 +242,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', {}), textResponse('done')]) // no command arg const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) // clean-exit hook allows; commandOf returned '' @@ -253,7 +253,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'n.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -266,7 +266,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'l.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -289,7 +289,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'l.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: 40 }) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -325,7 +325,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) @@ -366,7 +366,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) @@ -379,7 +379,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) // matcher didn't match → no hook ran → tool proceeded @@ -395,7 +395,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -408,7 +408,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'd.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const r = events(agent).find(e => e.type === 'tool/result') @@ -420,7 +420,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'bc.sh', '#!/usr/bin/env bash\necho \'{"decision":"block","reason":"bad","hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"ctx too"}}\'\n') }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const r = events(agent).find(e => e.type === 'tool/result') @@ -437,7 +437,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'cap.sh', `#!/usr/bin/env bash\ncat > "${cap}"\nexit 0\n`) }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 7 }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'number' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'number' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_input: { command: string } } @@ -449,7 +449,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'd.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) const ctx = await harness(join(d, 'hooks.json'), new MockAdapter([])) let ran = false - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) const { CallId } = await import('@deepseek-ai/dsh-llm') const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } }) expect(ran).toBe(false) // denied @@ -460,7 +460,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const d = dir() hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"x"}}\'\n') }] }] }) const ctx = await harness(join(d, 'hooks.json'), new MockAdapter([])) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const { CallId } = await import('@deepseek-ai/dsh-llm') const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } }) expect(result.isError).toBeFalsy() @@ -473,7 +473,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.bash.run = (() => Promise.reject(new Error('executor down'))) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -570,7 +570,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'cap.sh', `#!/usr/bin/env bash\ncat > "${cap}"\nexit 0\n`) }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'shell', { command: 'ls' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_name: string; tool_input: { command: string } } @@ -586,7 +586,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'shell', { command: 'ls' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false - ctx.tools.register(defineTool({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(false) // the matcher fired → the hook denied the tool @@ -620,7 +620,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) await ctx.plugin(HooksCodex, { configPath: join(serverDir, 'hooks.json'), model: 'm' }) ctx.llm.registerAdapter(['mock'], adapter) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const { SessionId } = await import('@deepseek-ai/dsh-session') const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } }) handle.agent.send([{ type: 'text', text: 'go' }]) diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index 8e2f086e97..18669920e0 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -6,7 +6,7 @@ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, RequestErrorDecision } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -166,7 +166,7 @@ describe('bounded transient retry policy', () => { ]) ;({ ctx: context } = await harness(adapter)) let toolExecutions = 0 - context.tools.register(defineTool({ + context.tools.register(defineContentToolFixture({ name: 'danger', description: 'must not run for a failed provider attempt', parameters: {}, diff --git a/packages/mcp/mcp-client/README.md b/packages/mcp/mcp-client/README.md index ebcf29fad5..252cd27bfc 100644 --- a/packages/mcp/mcp-client/README.md +++ b/packages/mcp/mcp-client/README.md @@ -56,8 +56,9 @@ Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call` - On connect: `listTools()` → registers each tool via `ctx.tools.register()` under its public name. - Listens for `notifications/tools/list_changed` → re-syncs; a failed re-sync keeps the previous generation registered. -- Tool execute: `client.callTool({ name: rawName, arguments }, { signal })` with timeout + abort support — the public name is never sent to the server. -- Image content in results is discarded with a placeholder (the harness has no image block type). +- Tool execute: `client.callTool({ name: rawName, arguments }, { signal })` with timeout + abort support—the public name is never sent to the server. +- Canonical success is `{ content: JsonValue[], structuredContent? }`; complete JSON MCP blocks survive for programmatic callers. A supported advertised `outputSchema` validates `structuredContent`; unsupported schema vocabulary falls back to unconstrained `JsonValue`. +- Native/model rendering keeps the existing text projection: text blocks join with newlines while image, audio, resource, and unsupported blocks become placeholders. - On disconnect/crash: all tools are unregistered; no auto-reconnect. ## Services consumed @@ -86,7 +87,7 @@ Prefix-stable while the discovered tool set and schemas are unchanged. A re-sync #### What the model sees -The public tool name and JSON arguments remain in assistant history. Text result blocks are joined with newlines into one retained text result; image, audio, resource, and unsupported blocks become short placeholders, and MCP `isError` results follow the registry's model-visible error path. +The public tool name and JSON arguments remain in assistant history. Text result blocks are joined with newlines into one retained Native text result; image, audio, resource, and unsupported blocks become short placeholders there. Their full JSON blocks and optional structured content remain in the execution-local canonical value, and MCP `isError` rejects the call through the registry's error path. #### Token effect @@ -101,4 +102,5 @@ Append-only; newly visible content follows the reusable request prefix and does - **Initial discovery is asynchronous** — plugin load does not wait for connection and `listTools()`, so a turn started immediately after boot or HMR can assemble before the MCP tools are registered. - **Tools are the only bridged MCP capability** — Resources and Prompts have no harness consumption surface and are deferred. - **Crash recovery is manual** — transport closure unregisters the server's tools, but reconnect requires an HMR reload or harness restart. -- **Non-text results are lossy** — image, audio, and resource payloads are replaced with placeholders, and a structured-only result has no model-visible structured representation. +- **Native non-text rendering is lossy** — image, audio, and resource payloads become placeholders in model context even though the execution-local canonical value preserves their JSON blocks. Richer Native multimedia projection is deferred. +- **Unsupported MCP output schemas are not enforced** — `structuredContent` falls back to `JsonValue` when the advertised schema uses vocabulary outside the harness subset. diff --git a/packages/mcp/mcp-client/src/index.ts b/packages/mcp/mcp-client/src/index.ts index 4a18f85ff5..2e16e33b44 100644 --- a/packages/mcp/mcp-client/src/index.ts +++ b/packages/mcp/mcp-client/src/index.ts @@ -22,6 +22,8 @@ import { syncTools } from './tools.ts' // Side-effect type import: declaration-merges `ctx.tools` onto Context. import type {} from '@deepseek-ai/dsh-tools' +export type { McpResult } from './tools.ts' + /** Cordis plugin name used by loader diagnostics. */ export const name = 'mcp-client' diff --git a/packages/mcp/mcp-client/src/tools.ts b/packages/mcp/mcp-client/src/tools.ts index 7b9217e814..1a8eabf416 100644 --- a/packages/mcp/mcp-client/src/tools.ts +++ b/packages/mcp/mcp-client/src/tools.ts @@ -16,6 +16,8 @@ import { createHash } from 'node:crypto' import type { Client } from '@modelcontextprotocol/sdk/client/index.js' import type { Context } from 'cordis' import type { ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools' +import { assertSupportedJsonSchema } from '@deepseek-ai/dsh-tools' +import type { JsonSchemaNode, JsonValue } from '@deepseek-ai/dsh-tools' /** Resolved options relevant to tool bridging. */ export interface ToolBridgeOptions { @@ -26,6 +28,12 @@ export interface ToolBridgeOptions { /** State for one sync generation: the current set of disposers keyed by public name. */ export type ToolDisposers = Map void> +/** Canonical MCP result exposed to Code Mode without discarding protocol blocks. */ +export type McpResult = { + content: JsonValue[] + structuredContent?: Structured +} + /** * DeepSeek function-name contract: at most 64 characters. Wire-protocol * constant, not configuration. @@ -105,6 +113,7 @@ export async function syncTools( name: publicName, description: tool.description ?? '', parameters: tool.inputSchema, + output: createOutput(tool.name, supportedOutputSchema(tool.outputSchema)), execute: createExecutor(client, tool.name, opts), }) } @@ -141,6 +150,36 @@ interface McpContentBlock { mimeType?: string } +/** Keep a supported advertised schema; unsupported MCP vocabulary falls back to JsonValue. */ +function supportedOutputSchema(candidate: unknown): JsonSchemaNode | undefined { + if (candidate === undefined) return undefined + try { + assertSupportedJsonSchema(candidate) + return candidate + } catch { + return undefined + } +} + +/** Build the canonical result schema and existing Native text projection. */ +function createOutput(rawName: string, structuredSchema: JsonSchemaNode | undefined): ToolDefinition['output'] { + return { + schema: { + type: 'object', + properties: { + content: { type: 'array', items: {} }, + structuredContent: structuredSchema ?? {}, + }, + required: ['content'], + additionalProperties: false, + }, + render(_args, value) { + const result = value as unknown as McpResult + return [{ type: 'text', text: extractText(result.content, rawName) }] + }, + } +} + /** * Create an execute function for one MCP tool. The executor closes over the * raw MCP tool name and calls `client.callTool` with it (never the public @@ -172,18 +211,24 @@ function createExecutor( // The SDK may return a legacy `toolResult` shape; normalize to content array. if (!('content' in result) || !Array.isArray(result.content)) { - const text = 'toolResult' in result + const rendered: unknown = 'toolResult' in result ? JSON.stringify(result.toolResult) : '(no output)' - return [{ type: 'text' as const, text }] + const text = typeof rendered === 'string' ? rendered : '(no output)' + if ('isError' in result && result.isError === true) throw new Error(text) + return { + content: [{ type: 'text', text }], + ...'structuredContent' in result && result.structuredContent !== undefined + ? { structuredContent: result.structuredContent as JsonValue } + : {}, + } } // Trust boundary: the SDK's return type erases to `any[]` due to the // union of CallToolResult | CompatibilityCallToolResult. We process each // element defensively in extractText (reading only .type/.text/.mimeType // with optional fallbacks). - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const content: McpContentBlock[] = result.content + const content = result.content as unknown as JsonValue[] const text = extractText(content, rawName) // MCP isError → throw so ToolRegistry produces an isError result for the model. @@ -191,7 +236,12 @@ function createExecutor( throw new Error(text) } - return [{ type: 'text', text }] + return { + content, + ...'structuredContent' in result && result.structuredContent !== undefined + ? { structuredContent: result.structuredContent as JsonValue } + : {}, + } } } @@ -203,10 +253,15 @@ function createExecutor( * Defensive: fields that the MCP spec declares required (mimeType, text) are * guarded with fallbacks because this is a network trust boundary. */ -function extractText(mcpContent: McpContentBlock[], toolName: string): string { +function extractText(mcpContent: JsonValue[], toolName: string): string { const parts: string[] = [] - for (const block of mcpContent) { + for (const value of mcpContent) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + parts.push('[unsupported content type: unknown]') + continue + } + const block = value as unknown as McpContentBlock switch (block.type) { case 'text': if (block.text !== undefined) parts.push(block.text) diff --git a/packages/mcp/mcp-client/tests/mcp-client.spec.ts b/packages/mcp/mcp-client/tests/mcp-client.spec.ts index 8fff832434..30e7a6297c 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.spec.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' +import ToolRegistry, { type JsonValue } from '@deepseek-ai/dsh-tools' import { publicToolName, syncTools, type ToolBridgeOptions } from '@deepseek-ai/dsh-mcp-client/src/tools.ts' import { createTransport } from '@deepseek-ai/dsh-mcp-client/src/transport.ts' import type { Config } from '@deepseek-ai/dsh-mcp-client' @@ -13,10 +13,12 @@ interface MockTool { name: string description?: string inputSchema: Record + outputSchema?: Record } interface MockCallResult { - content: Array<{ type: string; text?: string; mimeType?: string }> + content: JsonValue[] + structuredContent?: JsonValue isError?: boolean } @@ -114,7 +116,8 @@ describe('syncTools', () => { name: 'search', description: 'Native search', parameters: { type: 'object' }, - execute: async () => [{ type: 'text', text: 'native' }], + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value as string }] }, + execute: async () => 'native', }) const client = createMockClient([{ name: 'search', inputSchema: { type: 'object' } }]) @@ -156,7 +159,8 @@ describe('syncTools', () => { name: 'mcp__srv__taken', description: 'Squatter', parameters: { type: 'object' }, - execute: async () => [{ type: 'text', text: 'squatter' }], + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value as string }] }, + execute: async () => 'squatter', }) const client = createMockClient([ { name: 'free', inputSchema: { type: 'object' } }, @@ -221,6 +225,8 @@ describe('tool execution', () => { expect(result.isError).toBe(false) expect(result.content).toEqual([{ type: 'text', text: 'hello world' }]) + if (result.isError) throw new Error('expected MCP success') + expect(result.value).toEqual({ content: [{ type: 'text', text: 'hello world' }] }) // The wire sees the raw MCP name, never the public name. expect(client.callTool).toHaveBeenCalledWith( { name: 'echo', arguments: { msg: 'hi' } }, @@ -259,16 +265,85 @@ describe('tool execution', () => { expect(result.content).toEqual([{ type: 'text', text: 'line1\nline2' }]) }) - it('discards image content with placeholder', async () => { + it('preserves full JSON MCP blocks while Native rendering uses placeholders', async () => { + const blocks = [ + { type: 'text', text: 'before' }, + { type: 'image', mimeType: 'image/png', data: 'base64-data', annotations: { audience: ['assistant'] } }, + ] satisfies JsonValue[] const client = createMockClient( [{ name: 'img', inputSchema: { type: 'object' } }], - { content: [{ type: 'text', text: 'before' }, { type: 'image', mimeType: 'image/png' }] }, + { content: blocks }, ) await syncTools(client as never, ctx, defaultOpts, new Map()) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__img', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: 'before\n[image: image/png, content discarded]' }) + if (result.isError) throw new Error('expected MCP success') + expect(result.value).toEqual({ content: blocks }) + }) + + it('preserves primitive JSON MCP blocks while Native rendering marks them unsupported', async () => { + const blocks = [42, null, ['nested']] satisfies JsonValue[] + const client = createMockClient( + [{ name: 'primitive-blocks', inputSchema: { type: 'object' } }], + { content: blocks }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ + callId: CallId('primitive'), name: 'mcp__srv__primitive-blocks', arguments: {}, + }) + + expect(result.content[0]).toEqual({ + type: 'text', + text: '[unsupported content type: unknown]\n[unsupported content type: unknown]\n[unsupported content type: unknown]', + }) + if (result.isError) throw new Error('expected primitive MCP blocks to remain a successful JSON value') + expect(result.value).toEqual({ content: blocks }) + }) + + it('validates structuredContent when the advertised output schema is supported', async () => { + const outputSchema = { + type: 'object', + additionalProperties: false, + properties: { answer: { type: 'integer' } }, + required: ['answer'], + } + const valid = createMockClient( + [{ name: 'structured', inputSchema: { type: 'object' }, outputSchema }], + { content: [{ type: 'text', text: '42' }], structuredContent: { answer: 42 } }, + ) + await syncTools(valid as never, ctx, defaultOpts, new Map()) + const success = await ctx.tools.execute({ callId: CallId('valid'), name: 'mcp__srv__structured', arguments: {} }) + if (success.isError) throw new Error('expected supported structuredContent to validate') + expect(success.value).toEqual({ content: [{ type: 'text', text: '42' }], structuredContent: { answer: 42 } }) + + const invalidCtx = await mountRegistry() + const invalid = createMockClient( + [{ name: 'structured', inputSchema: { type: 'object' }, outputSchema }], + { content: [{ type: 'text', text: 'wrong' }], structuredContent: { answer: 'forty-two' } }, + ) + await syncTools(invalid as never, invalidCtx, defaultOpts, new Map()) + const failure = await invalidCtx.tools.execute({ callId: CallId('invalid'), name: 'mcp__srv__structured', arguments: {} }) + expect(failure.error).toMatchObject({ info: { code: 'INVALID_TOOL_OUTPUT' } }) + expect(failure.content[0]?.type === 'text' ? failure.content[0].text : '') + .toContain('value.structuredContent.answer') + }) + + it('falls back to JsonValue for unsupported advertised output schemas', async () => { + const client = createMockClient( + [{ + name: 'future-schema', + inputSchema: { type: 'object' }, + outputSchema: { type: 'object', patternProperties: { '^x-': { type: 'string' } } }, + }], + { content: [], structuredContent: ['kept', { nested: true }] }, + ) + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('fallback'), name: 'mcp__srv__future-schema', arguments: {} }) + if (result.isError) throw new Error('unsupported MCP output schemas must fall back') + expect(result.value).toEqual({ content: [], structuredContent: ['kept', { nested: true }] }) }) it('maps isError to an error result via throw', async () => { @@ -282,6 +357,7 @@ describe('tool execution', () => { expect(result.isError).toBe(true) expect(result.content[0]).toEqual({ type: 'text', text: 'Error: something went wrong' }) + expect('value' in result).toBe(false) }) it('passes abort signal to callTool', async () => { @@ -313,6 +389,38 @@ describe('tool execution', () => { expect(result.isError).toBe(false) expect(result.content[0]).toEqual({ type: 'text', text: '{"key":"value"}' }) }) + + it('preserves structuredContent on a successful legacy result', async () => { + const client = createMockClient([{ name: 'legacy-structured', inputSchema: { type: 'object' } }]) + client.callTool.mockResolvedValue({ + toolResult: 'legacy', + structuredContent: { answer: 42 }, + }) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ + callId: CallId('legacy-structured'), name: 'mcp__srv__legacy-structured', arguments: {}, + }) + + if (result.isError) throw new Error('expected legacy structured result success') + expect(result.value).toEqual({ + content: [{ type: 'text', text: '"legacy"' }], + structuredContent: { answer: 42 }, + }) + }) + + it('maps a legacy isError reply to failure', async () => { + const client = createMockClient([{ name: 'legacy-error', inputSchema: { type: 'object' } }]) + client.callTool.mockResolvedValue({ toolResult: { reason: 'nope' }, isError: true }) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ + callId: CallId('legacy-error'), name: 'mcp__srv__legacy-error', arguments: {}, + }) + + expect(result.isError).toBe(true) + expect(result.error?.message).toBe('{"reason":"nope"}') + }) }) describe('tool execution edge cases', () => { @@ -423,7 +531,7 @@ describe('tool execution edge cases', () => { const client = createMockClient( [{ name: 'legacy2', inputSchema: { type: 'object' } }], ) - client.callTool.mockResolvedValue({}) + client.callTool.mockResolvedValue({ toolResult: undefined, structuredContent: undefined }) await syncTools(client as never, ctx, defaultOpts, new Map()) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__legacy2', arguments: {} }) @@ -431,6 +539,18 @@ describe('tool execution edge cases', () => { expect(result.content[0]).toEqual({ type: 'text', text: '(no output)' }) }) + it('handles a legacy result with neither content nor toolResult', async () => { + const client = createMockClient( + [{ name: 'legacy-empty', inputSchema: { type: 'object' } }], + ) + client.callTool.mockResolvedValue({}) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('legacy-empty'), name: 'mcp__srv__legacy-empty', arguments: {} }) + + expect(result.content[0]).toEqual({ type: 'text', text: '(no output)' }) + }) + it('handles isError with non-text content (fallback error message)', async () => { const client = createMockClient( [{ name: 'err_notext', inputSchema: { type: 'object' } }], diff --git a/packages/session-persistence/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts index 84386c016b..d36ed98234 100644 --- a/packages/session-persistence/session-persistence/tests/contract.ts +++ b/packages/session-persistence/session-persistence/tests/contract.ts @@ -149,7 +149,7 @@ export function runPersistenceContract(name: string, make: () => Promise e.type === 'tool/result') expect(synthetic?.type === 'tool/result' && synthetic.data).toMatchObject({ - callId: CallId('call-x'), isError: true, error: { code: 'interrupted' }, + callId: CallId('call-x'), isError: true, error: { info: { code: 'interrupted' } }, }) // The synthetic result carries the SAME callId as the orphaned tool-call, // so deriveMessages() pairs them — no provider-invalid dangling call. diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index 89a6e843ab..578c6e7300 100644 --- a/packages/skill/tool-skill/README.md +++ b/packages/skill/tool-skill/README.md @@ -16,7 +16,7 @@ The plugin contributes one user-role `` catalog through `agent/ |---|---|---| | `name` | string (required) | Exact kebab-case skill name from the available skills listing. | -Execution uses the calling agent's `session.header.cwd` so workspace-sensitive providers resolve the winning skill. A successful call returns one text result containing ``, ``, and ``. +Execution uses the calling agent's `session.header.cwd` so workspace-sensitive providers resolve the winning skill. A successful call returns canonical `{ name, provider, resourceBase?, content }`, excluding catalog ranking and provider-internal machinery; its Native renderer produces one text result containing ``, ``, and ``. Resource guidance resolves only paths or URLs explicitly referenced by the instructions against `resourceBase`; scripts, references, and assets load on demand, and the result does not enumerate a skill directory. Local providers may supply a directory, while remote or embedded providers may supply a URL or opaque loading guidance. diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index 50c4b0db74..f5fbd1dff5 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -42,6 +42,46 @@ export function apply(ctx: Context, config: Config = {}): void { parameters: { name: { type: 'string', required: true, description: 'The exact skill name from the available skills list.' }, }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + name: { type: 'string', required: true }, + provider: { type: 'string', required: true }, + resourceBase: { + oneOf: [ + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'directory' }, + path: { type: 'string', required: true }, + }, + }, + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'url' }, + url: { type: 'string', required: true }, + }, + }, + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'opaque' }, + description: { type: 'string', required: true }, + }, + }, + ], + }, + content: { type: 'string', required: true }, + }, + }, + render: (_args, value) => [{ type: 'text', text: renderSkillContent(value) }], + }, async execute(args, exec) { if (!isSkillName(args.name)) { throw new Error(`invalid skill name "${args.name}"`) @@ -53,7 +93,14 @@ export function apply(ctx: Context, config: Config = {}): void { if (skill.disableModelInvocation === true) { throw new Error(`skill "${args.name}" is not available for model invocation`) } - return [{ type: 'text', text: renderSkillContent(skill) }] + return { + name: skill.name, + provider: skill.provider, + ...skill.resourceBase !== undefined ? { + resourceBase: { ...skill.resourceBase }, + } : {}, + content: skill.content, + } }, presentCall(args) { return { card: 'generic', title: `Load skill ${args.name}`, kind: 'read', rawInput: args.name } @@ -77,7 +124,7 @@ export function apply(ctx: Context, config: Config = {}): void { }) } -function renderSkillContent(skill: SkillDefinition): string { +function renderSkillContent(skill: Pick): string { const resourceHint = renderResourceHint(skill) return [ ``, @@ -92,7 +139,7 @@ function renderSkillContent(skill: SkillDefinition): string { ].join('\n') } -function renderResourceHint(skill: SkillDefinition): string[] { +function renderResourceHint(skill: Pick): string[] { const base = skill.resourceBase if (base === undefined) { return [ @@ -116,8 +163,10 @@ function renderResourceHint(skill: SkillDefinition): string[] { `Resources for this skill: ${escapeText(base.description)}`, 'Load referenced resources only as needed.', ] + /* v8 ignore start -- SkillResourceBase is a closed union; a future kind must fail compilation here. */ default: return assertNever(base, 'SkillResourceBase.kind') + /* v8 ignore stop */ } } diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index cab7f4aa02..843c14c93f 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -6,7 +6,7 @@ import { Context } from 'cordis' import { CallId, type Message } from '@deepseek-ai/dsh-llm' import { createScope, type Scope } from '@deepseek-ai/dsh-scope' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import SkillService from '@deepseek-ai/dsh-skill' import * as SkillLocal from '@deepseek-ai/dsh-skill-local' @@ -185,7 +185,7 @@ describe('dsh-tool-skill', () => { const ctx = await setup(home) ctx.skills.register({ name: 'listed-skill', description: 'Listed', source: 'runtime', content: 'body' }) const { agent, scope } = await mintAgentScope(ctx, '/workspace') - scope.ctx.tools.register(defineTool({ + scope.ctx.tools.register(defineContentToolFixture({ name: 'skill', description: 'A scoped tool with unrelated semantics.', parameters: {}, @@ -226,6 +226,13 @@ describe('dsh-tool-skill', () => { }) expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected skill success') + expect(result.value).toEqual({ + name: 'project-skill', + provider: 'local', + resourceBase: { kind: 'directory', path: join(project, '.dsh/skills/project-skill') }, + content: 'Project instructions.', + }) const block = result.content[0] expect(block?.type).toBe('text') if (block?.type !== 'text') throw new Error('expected text skill result') @@ -283,7 +290,7 @@ describe('dsh-tool-skill', () => { expect(provider.content[0].text).toContain('\nResources for this skill are managed by provider "runtime".\nLoad referenced resources only as needed.\n') }) - it('fails loud on an unknown resource base kind', async () => { + it('rejects an unknown resource-base kind at the canonical output boundary', async () => { const home = await tempDir('tool-resource-assert-never') const ctx = await setup(home) ctx.skills.register({ @@ -298,9 +305,10 @@ describe('dsh-tool-skill', () => { const result = await ctx.tools.execute({ callId: CallId('c5'), name: 'skill', arguments: { name: 'rogue-resource-skill' } }) expect(result.isError).toBe(true) + expect(result.error?.info?.code).toBe('INVALID_TOOL_OUTPUT') const block = result.content[0] if (block?.type !== 'text') throw new Error('expected text tool result') - expect(block.text).toContain('unreachable variant') + expect(block.text).toContain('value.resourceBase') }) it('returns isError for unknown, invalid, and model-disabled skills', async () => { diff --git a/packages/spill/spill-policy/README.md b/packages/spill/spill-policy/README.md index 936f254d6a..9b746f27bc 100644 --- a/packages/spill/spill-policy/README.md +++ b/packages/spill/spill-policy/README.md @@ -13,7 +13,7 @@ This plugin registers **no service** and owns no storage or preview mechanics: p ## Behavior 1. Let the tool run (delegates via `next()`, so it bounds whatever a downstream hook accepted). -2. Skip `read` (avoids a `read → spill → read again` loop) and any non-`accept` decision (a `block`'s corrective feedback passes through). +2. Skip nested executions (`exec.parent` is present), accepted value replacements (the registry must revalidate and rerender them), `read` (avoids a `read → spill → read again` loop), and any non-`accept` decision (a `block`'s corrective feedback passes through). 3. Flatten the accepted content only when it is **plain text** (all `text` blocks); a result with any non-text block is left untouched. 4. If its UTF-8 size is `≤ maxInlineBytes`, leave it unchanged. 5. Otherwise save the full text and replace the result with a preview + this notice, sized so the whole replacement (preview + blank line + notice) stays within `maxInlineBytes` — the notice's byte cost is reserved out of the budget, so the preview shrinks to fit and the model-facing result never exceeds the cap: @@ -26,11 +26,11 @@ This plugin registers **no service** and owns no storage or preview mechanics: p When the notice alone fills the budget (a tiny cap or a long locator) the preview is empty and only the notice is returned. If even that notice-only replacement would exceed `maxInlineBytes`, the policy keeps the inline result — it never emits a replacement over the cap (and a within-cap replacement is always smaller than the original, so this also means spilling never adds bytes). -**Best-effort:** no session owner, no `ctx.spillStore` backend, or a `saveText` rejection ⇒ the policy logs a warning and returns the original result. A spill failure never turns a successful call into an `isError` or hides the inline result. +**Best-effort:** no session owner, no `ctx.spillStore` backend, or a `saveText` rejection ⇒ the policy logs a warning and returns the original result. A spill failure never turns a successful call into an `isError` or hides the inline result. A successful replacement changes only `content`; the canonical programmatic value is preserved. ## Scope -The policy sees only the FINAL formatted tool result — not a tool's internal resource. If a provider already truncated (e.g. `web-fetch-local.maxBodyChars`), the spill artifact holds the full formatted result the tool returned, not the full original source. Provider/resource caps stay mandatory and separate. Tool-owned early spill (bash streams, subagent rollouts) is future work — see the [tool output spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md). +The policy sees only the FINAL formatted surface result—not a tool's internal resource or canonical value. If a provider already truncated (e.g. `web-fetch-local.maxBodyChars`), the spill artifact holds the full formatted result the tool returned, not the full original source. Provider/resource caps stay mandatory and separate. `glob`/`grep` own item-level surface spill because their complete acquired values still exist before rendering; bash streams own acquisition-time spill. See the [tool output spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md). ## Model Experience @@ -38,7 +38,7 @@ The policy sees only the FINAL formatted tool result — not a tool's internal r #### What the model sees -Results at or below `maxInlineBytes`, `read` results, blocked decisions, and results containing non-text blocks are unchanged. An oversized plain-text result becomes a bounded head/tail preview followed by `(Omitted bytes. Full formatted result stored at: . )`; storage or ownership failures leave the original result visible. +Results at or below `maxInlineBytes`, nested results, `read` results, blocked decisions, and results containing non-text blocks are unchanged. An oversized plain-text surface result becomes a bounded head/tail preview followed by `(Omitted bytes. Full formatted result stored at: . )`; storage or ownership failures leave the original result visible. #### Token effect diff --git a/packages/spill/spill-policy/src/index.ts b/packages/spill/spill-policy/src/index.ts index b7ac4a31fc..c2abd094ed 100644 --- a/packages/spill/spill-policy/src/index.ts +++ b/packages/spill/spill-policy/src/index.ts @@ -16,15 +16,20 @@ * - Plain-text results only: a result carrying any non-text block is left * untouched (the policy knows only the final formatted text, not tool * internals). + * - Nested composite calls are skipped; only their outer surface result may + * become model-facing and spillable. + * - Accepted value replacements pass through for registry revalidation and + * rendering; this presentation policy cannot also replace content in the + * same mutually exclusive decision. * - `read` is skipped to avoid a `read → spill → read again` loop. * - Best-effort: no session owner, no `ctx.spillStore` backend, or a save * failure ⇒ log and return the original result. A spill failure must NEVER * turn a successful tool call into an `isError` or hide the inline result. * * It COMPOSES with other post-execute listeners: it delegates via `next()` and - * bounds the resulting `accept` content, so a hook that replaced the content - * still has its replacement bounded, and a `block` decision passes through - * unchanged. + * bounds the resulting content projection, so a hook that replaced content + * still has its replacement bounded, while value replacements and `block` + * decisions pass through unchanged. * * @module @deepseek-ai/dsh-spill-policy */ @@ -109,7 +114,8 @@ export function apply(ctx: Context, config: Config): void { // accepted plain-text results, never corrective feedback. const decision = await next() // Skip `read` to avoid a read → spill → read again loop. - if (decision.kind !== 'accept' || exec.name === 'read') return decision + if (decision.kind !== 'accept' || Object.hasOwn(decision, 'value') + || exec.parent !== undefined || exec.name === 'read') return decision const content = decision.content ?? result.content const text = flattenPlainText(content) diff --git a/packages/spill/spill-policy/tests/spill-policy.spec.ts b/packages/spill/spill-policy/tests/spill-policy.spec.ts index 2449f26a8c..e01703525f 100644 --- a/packages/spill/spill-policy/tests/spill-policy.spec.ts +++ b/packages/spill/spill-policy/tests/spill-policy.spec.ts @@ -15,8 +15,8 @@ import { CallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import type { ToolExecution } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import type { ToolExecution, ToolExecutionToken } from '@deepseek-ai/dsh-tools' import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill' import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill' import * as SpillPolicy from '@deepseek-ai/dsh-spill-policy' @@ -39,7 +39,7 @@ class StubStore extends SpillStore { /** A tool returning `text` verbatim (name configurable so we can register `read`). */ function textTool(name: string, text: string) { - return defineTool({ + return defineContentToolFixture({ name, description: name, parameters: {}, @@ -159,7 +159,7 @@ describe('oversized plain-text replacement', () => { it('leaves a result with a non-text block unchanged', async () => { const { ctx, spill } = await setup({ maxInlineBytes: 5 }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'mixed', description: 'mixed', parameters: {}, @@ -183,6 +183,21 @@ describe('read skip', () => { }) }) +describe('nested-call skip', () => { + it('leaves nested composite results complete and spillable only through their outer call', async () => { + const { ctx, spill } = await setup({ maxInlineBytes: 10 }) + const body = 'x'.repeat(1000) + ctx.tools.register(textTool('nested', body)) + const nested = { + ...exec('nested'), + parent: Symbol('outer') as ToolExecutionToken, + } + const result = await ctx.tools.execute(nested) + expect(textOf(result.content)).toBe(body) + expect(spill?.saves).toHaveLength(0) + }) +}) + describe('best-effort fallback', () => { it('keeps the original result when saveText fails', async () => { const { ctx, spill } = await setup({ maxInlineBytes: 10 }) @@ -238,6 +253,21 @@ describe('composition', () => { expect(textOf(result.content)).toContain('Full formatted result stored at') expect(result.additionalContexts).toEqual([context]) }) + + it('passes a downstream value replacement through for registry rendering', async () => { + const { ctx, spill } = await setup({ maxInlineBytes: 10 }) + const replacement = [{ type: 'text' as const, text: 'z'.repeat(500) }] + ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: replacement })) + ctx.tools.register(textTool('small', 'tiny')) + + const result = await ctx.tools.execute(exec('small')) + + expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected replacement success') + expect(result.value).toEqual(replacement) + expect(textOf(result.content)).toBe('z'.repeat(500)) + expect(spill?.saves).toHaveLength(0) + }) }) describe('cap invariant', () => { diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index d51fc53e5d..194ffeea47 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -60,7 +60,7 @@ Independent of the parent request cache. The child's later history is append-onl #### What the model sees -A structured run adds the structured-output instruction below. It also adds a child-scoped `structured_output` definition with exact description `Report your final structured result. Call this exactly once, when your answer is complete; the arguments must match this tool's parameter schema exactly.` and the requested schema. This runtime-only definition is outside the generated shipped [tool package map](../../../docs/tool-catalog.md#tool-package-map). Success returns `Structured output recorded.`; a later call becomes ``Error: structured output already recorded: the run is complete, so `` is not executed``. +A structured run adds the structured-output instruction below. It also adds a child-scoped `structured_output` definition with exact description `Report your final structured result. Call this exactly once, when your answer is complete; the arguments must match this tool's parameter schema exactly.` and the requested schema. This runtime-only definition is outside the generated shipped [tool package map](../../../docs/tool-catalog.md#tool-package-map). Its canonical acknowledgement is `{ recorded: true }`, rendered as `Structured output recorded.`; a later call becomes ``Error: structured output already recorded: the run is complete, so `` is not executed``. ##### Structured-output instruction diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index 9d2cce1570..9adf5899fe 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -12,7 +12,7 @@ import type { Context } from 'cordis' import type { ContinuationStop } from '@deepseek-ai/dsh-agent' -import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' +import type { ToolSchema } from '@deepseek-ai/dsh-llm' import type { ToolExecution } from '@deepseek-ai/dsh-tools' import { ToolArgsError, validateJsonSchemaValue, type ObjectJsonSchema } from '@deepseek-ai/dsh-tools' @@ -74,7 +74,16 @@ export function attachStructuredRuntime(childCtx: Context, schema: ObjectJsonSch childCtx.tools.register({ ...schemaEntry, - execute(args: unknown, exec: ToolExecution): Promise { + output: { + schema: { + type: 'object', + properties: { recorded: { type: 'boolean', const: true } }, + required: ['recorded'], + additionalProperties: false, + }, + render: () => [{ type: 'text', text: 'Structured output recorded.' }], + }, + execute(args: unknown, exec: ToolExecution): Promise<{ recorded: true }> { const violations = validateJsonSchemaValue(schema, args) // ToolArgsError → isError result with INVALID_ARGS: the model retries // within the same turn, exactly like a schema-validated defineTool call. @@ -83,7 +92,7 @@ export function attachStructuredRuntime(childCtx: Context, schema: ObjectJsonSch // waterfalls may still turn the success into an error. ToolRegistry has // already frozen model-bound arguments at the actual input boundary. staged.set(exec, { value: args }) - return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }]) + return Promise.resolve({ recorded: true }) }, }) diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 56a78a225f..a41ede8b86 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -8,7 +8,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import type { Config as ToolConfig, ObjectJsonSchema } from '@deepseek-ai/dsh-tools' -import { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture, RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { startInProcessRun } from '../src/index.ts' import { @@ -85,10 +85,15 @@ describe('in-process structured output', () => { const { ctx, parent } = await setup([ toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42, note: 'done' }), ]) + let acknowledgement: unknown + ctx.on('tools/result', (exec, toolResult) => { + if (exec.name === STRUCTURED_OUTPUT_TOOL && !toolResult.isError) acknowledgement = toolResult.value + }) const run = await ctx.subagents.start('spawn', structuredRequest(parent)) const result = await run.result expect(result.stopReason).toBe('completed') expect(result.structured).toEqual({ answer: 42, note: 'done' }) + expect(acknowledgement).toEqual({ recorded: true }) await run.dispose() }) @@ -119,15 +124,15 @@ describe('in-process structured output', () => { ] as Script[number] const { ctx, parent } = await setup([response]) let sideEffectRan = false - ctx.tools.register({ + ctx.tools.register(defineContentToolFixture({ name: 'side_effect', description: 'probe', - parameters: { type: 'object', properties: {} }, + parameters: {}, execute(): Promise { sideEffectRan = true return Promise.resolve([{ type: 'text', text: 'ran' }]) }, - }) + })) const run = await ctx.subagents.start('spawn', structuredRequest(parent)) const result = await run.result expect(result.stopReason).toBe('completed') @@ -147,15 +152,15 @@ describe('in-process structured output', () => { ] as Script[number] const { ctx, parent } = await setup([response]) let sideEffectRan = false - ctx.tools.register({ + ctx.tools.register(defineContentToolFixture({ name: 'side_effect', description: 'probe', - parameters: { type: 'object', properties: {} }, + parameters: {}, execute(): Promise { sideEffectRan = true return Promise.resolve([{ type: 'text', text: 'ran' }]) }, - }) + })) const run = await ctx.subagents.start('spawn', structuredRequest(parent)) // Registered after the child and prepended: this listener returns allow // after every downstream pre-execute decision. The service-owned guard @@ -184,15 +189,15 @@ describe('in-process structured output', () => { ] as Script[number] const { ctx, parent } = await setup([response]) let sideEffectRan = false - ctx.tools.register({ + ctx.tools.register(defineContentToolFixture({ name: 'side_effect', description: 'probe', - parameters: { type: 'object', properties: {} }, + parameters: {}, execute(): Promise { sideEffectRan = true return Promise.resolve([{ type: 'text', text: 'ran' }]) }, - }) + })) const run = await ctx.subagents.start('spawn', structuredRequest(parent)) const result = await run.result // The call ran BEFORE captured was set: the deny gate only guards the @@ -589,12 +594,12 @@ describe('in-process structured output', () => { ]) // A global tool sorts lexicographically after structured_output, while a // global section above the 190 band follows the capture instruction. - ctx.tools.register({ + ctx.tools.register(defineContentToolFixture({ name: 'zz_probe', description: 'probe', - parameters: { type: 'object', properties: {} }, + parameters: {}, execute: () => Promise.resolve([{ type: 'text', text: 'x' }]), - }) + })) ctx.systemPrompt.section({ name: 'after-band', order: 200, text: 'AFTER-BAND' }) const run = await ctx.subagents.start('spawn', structuredRequest(parent)) await run.result @@ -646,7 +651,7 @@ describe('in-process structured output', () => { agent: parent, }) expect(result.isError).toBe(true) - expect(result.error?.code).toBe('UNKNOWN_TOOL') + expect(result.error?.info?.code).toBe('UNKNOWN_TOOL') }) it('a structured_output call with NO calling agent at all is UNKNOWN_TOOL', async () => { @@ -657,7 +662,7 @@ describe('in-process structured output', () => { arguments: { answer: 1 }, }) expect(result.isError).toBe(true) - expect(result.error?.code).toBe('UNKNOWN_TOOL') + expect(result.error?.info?.code).toBe('UNKNOWN_TOOL') }) it('a failed execution stage is discarded and never promoted by a later call', async () => { diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 7de5f6f4d6..368e3700c9 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -10,6 +10,7 @@ import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-sub import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as spawn from '../src/index.ts' import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' type Script = ConstructorParameters[0] @@ -383,10 +384,10 @@ describe('dsh-subagent-spawn', () => { toolCallResponse('c1', 'forbidden_tool', {}), textResponse('done'), ]) - ctx.tools.register({ + ctx.tools.register(defineContentToolFixture({ name: 'forbidden_tool', description: 'global', parameters: {}, execute: () => Promise.resolve([{ type: 'text', text: 'ran' }]), - }) + })) const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'do X' }], parent, diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index e2af5ac552..08e143f94d 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -6,9 +6,9 @@ The model-facing delegation tool over one configured `ctx.subagents` provider. C Each plugin instance binds one `provider` to one `toolName`; the model receives no provider selector. Load another distinctly named instance to expose another transport. The tool registers only while its provider exists, avoiding sibling load-order and provider-reload dependencies. Its description follows `provider.inheritsParentContext`: fresh children require standalone prompts, while forked children already see completed parent turns. -A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns final text; abort, refusal, token limit, and other failures become errored tool results without partial output. +A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text; abort, refusal, token limit, and other failures become errored tool results without partial output. -With `run_in_background: true`, the tool registers the parent-owned task before starting the provider. A task-owned signal covers pending startup and the child after the starting call returns. `task_kill` and owner disposal abort it. Settlement awaits startup rollback or child disposal, then maps completed final text, abort to `killed`, and other failures to `failed`. The task has no incremental read; generic task tools own later status, collection, cancellation, and notices. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md). +With `run_in_background: true`, the tool registers the parent-owned task before starting the provider and returns canonical `{ kind: 'background', taskId }`, rendered as `started background subagent task `. A task-owned signal covers pending startup and the child after the starting call returns. `task_kill` and owner disposal abort it. Settlement awaits startup rollback or child disposal, then maps completed final text, abort to `killed`, and other failures to `failed`. The task has no incremental read; generic task tools own later status, collection, cancellation, and notices. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md). `toolFilter` changes the child's global tool layer but is not a parent-derived authority ceiling. See the [agent-scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals). diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index bcd28c6ab1..2af2c58dc6 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -12,6 +12,7 @@ import z from 'schemastery' import { defineTool } from '@deepseek-ai/dsh-tools' import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { JsonValue } from '@deepseek-ai/dsh-session' import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent' import type { SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import type { TaskOutcome } from '@deepseek-ai/dsh-tasks' @@ -95,6 +96,16 @@ function outputText(blocks: ContentBlock[]): string { .join('') } +/** Render text blocks from the canonical JSON block array without trusting arbitrary values. */ +function outputValueText(values: JsonValue[]): string { + return values + .filter((value): value is { type: 'text'; text: string } => + typeof value === 'object' && value !== null && !Array.isArray(value) + && value.type === 'text' && typeof value.text === 'string') + .map(value => value.text) + .join('') +} + /** A non-`completed` stop reason means the child did not finish cleanly. */ function stopReasonError(result: SubagentResult): string | undefined { switch (result.stopReason) { @@ -268,7 +279,36 @@ export function apply(ctx: Context, config: Config): void { }, } : {}, }, - async execute(args, exec): Promise { + output: { + schema: { + oneOf: [ + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'background' }, + taskId: { type: 'string', required: true }, + }, + }, + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'foreground' }, + runId: { type: 'string', required: true }, + output: { type: 'array', required: true, items: { type: 'json' } }, + }, + }, + ], + }, + render: (_args, value) => [{ + type: 'text', + text: value.kind === 'background' + ? `started background subagent task ${value.taskId}` + : outputValueText(value.output), + }], + }, + async execute(args, exec) { const parent = exec.agent if (!parent) { // Non-agent callers provide no parent for delegation ownership. @@ -308,7 +348,7 @@ export function apply(ctx: Context, config: Config): void { } }, }) - return [{ type: 'text', text: `started background subagent task ${id}` }] + return { kind: 'background' as const, taskId: id } } const request = startRequest( @@ -327,7 +367,13 @@ export function apply(ctx: Context, config: Config): void { // The registry converts this throw to isError; partial output is not success. throw new Error(error) } - return [{ type: 'text', text: outputText(result.output) }] + return { + kind: 'foreground' as const, + runId: run.id, + // Content blocks already cross durable JSON boundaries elsewhere; + // the registry performs the authoritative lossless snapshot here. + output: result.output as unknown as JsonValue[], + } } finally { // Dispose before returning so no child session outlives the call. await run.dispose() diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 9e4d68c08c..a679b236fc 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -62,6 +62,12 @@ describe('dsh-tool-subagent', () => { const ctx = await setup({ provider: 'mock' }, { reply: 'child says hi' }) const result = await callSubagent(ctx, { description: 'do a thing', prompt: 'go research X' }) expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected subagent success') + expect(result.value).toEqual({ + kind: 'foreground', + runId: 'scripted-subagent:mock:parent-1', + output: [{ type: 'text', text: 'child says hi' }], + }) expect(text(result)).toBe('child says hi') }) @@ -637,6 +643,8 @@ describe('dsh-tool-subagent background mode', () => { const start = await callSubagent(ctx, { description: 'deep research', prompt: 'dig in', run_in_background: true }, { agent: parent }) expect(start.isError).toBe(false) + if (start.isError) throw new Error('expected background subagent success') + expect(start.value).toEqual({ kind: 'background', taskId: 'subagent-1' }) expect(text(start)).toBe('started background subagent task subagent-1') const collected = await ctx.tools.execute({ diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 91b4da4566..4a6b39be72 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -165,7 +165,7 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr // A result needs a prior matching call in the same step. (The converse // does NOT hold: a call may have no result — a throwing tool-execution // pipeline step ends the turn with no tool/result, which is legal.) - const syntheticInterrupted = event.data.isError && event.data.error?.code === 'interrupted' + const syntheticInterrupted = event.data.isError && event.data.error?.info?.code === 'interrupted' if (!trace.pendingCalls.has(event.data.callId) && !syntheticInterrupted) { throw new InvariantError(`tool/result for ${event.data.callId} with no prior tool/call in this step`) } diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index 4f066eff8c..e9a09ea356 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -217,7 +217,7 @@ describe('session-log invariants', () => { callId: CallId('crashed'), content: [{ type: 'text', text: 'interrupted' }], isError: true, - error: { name: 'InterruptedError', code: 'interrupted' }, + error: { message: 'interrupted', info: { name: 'InterruptedError', code: 'interrupted' } }, }, { surfaceOp: 'append' }) session.append('step/end', { turn: 1, step: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } }) @@ -500,7 +500,7 @@ describe('surface contract under the invariants composition', () => { callId: CallId('rewrite'), content: [{ type: 'text' as const, text: 'original' }], isError: true, - error: { name: 'ExitError', code: 'EXIT_1' }, + error: { message: 'exit 1', info: { name: 'ExitError', code: 'EXIT_1' } }, meta: { presentation: { kind: 'terminal', output: 'full output' } }, futureField: { nested: ['preserve', 1] }, } @@ -585,7 +585,7 @@ describe('surface contract under the invariants composition', () => { ['callId', { callId: CallId('forged') }], ['turn', { turn: 2 }], ['step', { step: 2 }], - ['error', { error: { name: 'ExitError', code: 'DIFFERENT' } }], + ['error', { error: { message: 'exit 1', info: { name: 'ExitError', code: 'DIFFERENT' } } }], ['meta', { meta: { presentation: { kind: 'generic' } } }], ['future data', { futureField: { nested: ['changed'] } }], ])('rejects a content rewrite with altered %s', async (_label, altered) => { diff --git a/packages/tasks/tool-tasks/README.md b/packages/tasks/tool-tasks/README.md index 7e1ab82971..69063b681c 100644 --- a/packages/tasks/tool-tasks/README.md +++ b/packages/tasks/tool-tasks/README.md @@ -10,6 +10,8 @@ The model-facing control surface for `ctx.tasks`: three kind-independent tools, All three use generic ACP cards: `read` for output and list, `execute` for kill. +Their canonical values are `{ text, task }`, `PublicTaskSnapshot[]`, and `{ outcome: 'cancellation-requested' | 'already-finished', task }`. A public snapshot carries id, kind, label, status/detail, and start/finish times; it deliberately omits `ownerSession` and the internal `reported` notice bit. Native renderers preserve the status and acknowledgement text above. + ## Completion notices An unreported completion injects `background task (: