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-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-06-20-generic-long-running-tool-runtime.md b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md index 4db0d78910..b5117b1045 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md @@ -23,6 +23,8 @@ Long-running tools are producers. `dsh-tool-bash` adapts a `BashProcess` into in The literal types live in the [task data-structure catalog](../../../../docs/core-data-structures/tasks.md). A producer calls `ctx.tasks.start()` with a kind, label, optional owning `Agent`, and a `run()` function. The runtime completes all failable preflight work before calling `run()` and invokes it once. After `run()` returns hooks, registration commits without another failable step; a producer cannot start work that lacks a collectable task id. +A model-facing producer exposes that committed id in its canonical success value, normally `{ kind: 'background', taskId }`; Native rendering may keep human-readable prose. A pre-aborted background call fails rather than returning a no-op because no task exists to satisfy the promised handle. Once registration publishes the id, cancellation belongs to the task's own controller and the task runtime: later cancellation of the producing tool call must not kill the published task. `task_kill`, owner disposal, and service teardown request cancellation; foreground execution remains coupled to the call's `exec.signal`. + The producer hooks define three responsibilities: - `cancel(reason?)` synchronously requests termination, is idempotent, and must cause `done` to settle. 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 82da8347a6..b7713fa8e0 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..d28fb0da87 --- /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: 8cd7df98758d6d4ac240fea21e7bbe0c89f26d1b +2026-07-20-canonical-tool-output-contract.zh.md: 0920e0c2c331a247ecd9a039a05c19c9dd2871bc 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..8cd7df9875 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md @@ -0,0 +1,79 @@ +# 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. Canonical-result provenance is scoped to the immutable dispatch token, so returning a cached result from another call or tool triggers normalization under the active declaration rather than bypassing it. + +```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 optional `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. When a tool declares `presentationMeta`, it is computed only for a direct surface call; a nested Code dispatch gets no metadata or result card. The outer `run_code` card instead reads final post-policy content and declares no presentation metadata. 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` | +| `lsp` | `{ kind: "locations", locations, resolvedWorkspaceRoot }` or `{ kind: "hover", hover }` | +| `bash` | `{ kind: "background", taskId }` or `{ kind: "foreground" } & BashRunResult` | +| `terminal_open` / `terminal_list` / `terminal_send` / `terminal_read` / `terminal_signal` / `terminal_close` | Public session snapshots, bounded read/send DTOs, signal/close outcomes, or a background task handle | +| `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? }] }` | +| `exit_plan_mode` | `{ approved: true }` | +| `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. Generic spill prepends and delegates its post-execute listener so an ordinary tool-owned asynchronous projection completes before generic byte bounding regardless of plugin load order. 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..0920e0c2c3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md @@ -0,0 +1,79 @@ +# 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` 包装层接收并返回规范的成功/失败联合;包装层自行产生的成功结果会再次通过已解析工具的输出声明完成归一化,而不会信任其独立编写的内容。每个规范结果只归属于一个不可变的分发 token;因此,如果包装层返回来自其他调用或工具的缓存结果,系统会依据当前生效的输出声明重新执行归一化,而不会绕过这一步。 + +```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` 时,系统只会为直接的外层调用计算它;嵌套 Code 分发没有元数据或结果卡片。外层 `run_code` 卡片则读取最终的 post-policy 内容,并且不声明展示元数据。通用以及工具自有的输出落盘投影同样跳过嵌套分发,因为它们的规范值永远不会进入模型上下文。 + +第一方工具在保持现有 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` | +| `lsp` | `{ kind: "locations", locations, resolvedWorkspaceRoot }` 或 `{ kind: "hover", hover }` | +| `bash` | `{ kind: "background", taskId }` 或 `{ kind: "foreground" } & BashRunResult` | +| `terminal_open` / `terminal_list` / `terminal_send` / `terminal_read` / `terminal_signal` / `terminal_close` | 公开会话快照、有界的读取/发送 DTO、信号/关闭操作结果,或后台任务句柄 | +| `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? }] }` | +| `exit_plan_mode` | `{ approved: true }` | +| `cordis_inspect` / `cordis_mount` / `cordis_unmount` | 检查文本或类型化的动态挂载句柄 | +| `structured_output` | `{ recorded: true }` | +| `run_code` | `{ logs: string[], result?: JsonValue }` | + +提供方和执行器的采集上限仍会实际限制规范值。仅用于格式化的限制归 `render` 所有;例如,`glob` 和 `grep` 会在 `value` 中保留所有已采集项,而其 Native 投影仍只保留配置指定的第一页,并尽力将完整展示内容写入落盘文件。通用落盘机制会前置注册其 post-execute 监听器,并让该监听器先向后委托,因此无论插件加载顺序如何,普通工具自有的异步投影都会在通用字节数上限处理之前完成。文件系统变更工具根据 `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/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..16852c1004 --- /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: 09945c413ffe5924c74076648cdf3da60c3e18c9 +2026-07-20-unified-json-value-schema-dsl.zh.md: 00a7a199613ea857a7815f1c7794781f143a3896 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..09945c413f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md @@ -0,0 +1,35 @@ +# 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. Schema records contain only own enumerable string keys, schema arrays are dense intrinsic arrays, and supported keywords are read as own properties; custom prototypes, inherited constraints, symbols, and JSON-invisible decorations therefore cannot make compilation, projection, and validation observe different declarations. Intrinsic plain Object and Array containers remain plain across JavaScript realms, while subclasses and forged constructor prototypes remain exotic. + +`InferValue` and `InferArgs

` derive TypeScript values from the same declarations that `valueSchemaSpecToJsonSchema()` and `parameterSchemaSpecToJsonSchema()` compile. Exact inference is bounded to 16 container levels and then uses `JsonValue`, preventing TypeScript's type-instantiation stack from becoming the authoring limit. `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. Author compilation, raw-schema assertion, value validation, schema-to-TypeScript rendering, registry detachment, and dynamic Cordis cross-realm normalization and cloning use explicit work stacks, so runtime nesting is limited by available memory rather than the JavaScript call stack. + +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. The dynamic boundary rejects JSON-invisible record keys and exotic schema arrays before normalization, so it cannot silently discard a constraint or consume custom iteration semantics. + +## 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. +- Bounded type inference retains useful exact types for ordinary declarations and degrades unusually deep tails to `JsonValue`; runtime schema enforcement remains exact at every depth. +- 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, inference, deep nesting across core and dynamic projections, JSON-invisible dynamic keys, and exotic schema arrays. 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..00a7a19961 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md @@ -0,0 +1,35 @@ +# 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 保留标准的默认开放语义。schema 记录只能包含自有且可枚举的字符串键,schema 数组必须是稠密的内建数组,系统只从自有属性读取受支持的关键字;因此,自定义原型、继承的约束、symbol 和 JSON 不可见的附加内容都无法让编译、投影和校验观察到不同的声明。内建的普通 Object 和 Array 容器跨 JavaScript 运行域后仍视为普通容器,而子类和伪造构造函数的原型仍视为非普通对象。 + +`InferValue` 和 `InferArgs

` 根据同一份声明推导 TypeScript 值,`valueSchemaSpecToJsonSchema()` 和 `parameterSchemaSpecToJsonSchema()` 也将这些声明编译为 JSON Schema。精确类型推导以 16 层容器为界,超过后使用 `JsonValue`,从而避免 TypeScript 的类型实例化栈限制作者能声明的嵌套深度。`assertSupportedJsonSchema()` 会拒绝不受支持或位置错误的关键字;`validateJsonSchemaValue()` 则以无损 `JsonValue` 边界校验受支持的子集,不允许 `undefined`、负零、非有限数、稀疏数组、循环引用、非普通对象、函数、symbol 及其他需要强制转换的值。作者侧 schema 编译、原始 schema 断言、值校验、schema 到 TypeScript 的渲染、注册表数据分离,以及动态 Cordis 的跨运行域规范化与克隆均使用显式工作栈,因此运行时嵌套只受可用内存限制,不受 JavaScript 调用栈限制。 + +对象根限制属于消费方规则,不属于 schema 词汇本身。subagent 和工作流中由调用方定义的结构化输出通过 `assertObjectJsonSchema()` 和 `ObjectJsonSchema` 保持对象根限制;工具输出可以使用任意根类型。动态 Cordis 注册会把跨 JavaScript 运行域传入的 schema 重建为当前运行时持有的 JSON,保留原始包装层的默认开放语义,并要求直接使用 DSL 声明的对象明确选择开放方式,然后再调用同一编译器。动态边界会在规范化之前拒绝 JSON 不可见的记录键和非普通 schema 数组,因此不会静默丢弃约束,也不会触发自定义迭代逻辑。 + +## 备选方案 + +- **保留两套独立的参数与结构化输出 schema 系统:**不予采纳。每新增一种输出结构,都必须分别修改类型推导、编译、校验和代码生成,而这种重复并未形成有意义的职责边界。 +- **采用完整 JSON Schema 或 Ajv:**不予采纳。harness 必须拒绝所有无法投影到生成 SDK 和校验器中的结构;如果接受更大的语言子集,强制执行能力和模型指引就会与事实不符。 +- **让所有对象默认开放或默认封闭:**不予采纳。这两种选择都会隐藏一项影响重大的作者决策。只有保持旧有形态的隐式参数根对象和外部原始 schema 才有意保留默认值。 +- **把 `oneOf` 定义为首个匹配分支:**不予采纳。这样一来,分支顺序会改变校验语义,重叠分支也会掩盖值的歧义。 + +## 影响 + +- 参数校验、输出校验、schema 到 TypeScript 的代码生成、subagent/工作流门禁和动态注册共用一套强制执行的词汇。 +- 输出声明可以推导对象、数组、标量或 null 根类型;subagent/工作流的结构化输出仍在其现有服务边界保持对象根限制。 +- 显式的对象开放方式和类型正确的字面量约束会让格式错误的声明在编写或注册阶段快速失败,而不是拖到后续模型调用时才失败。 +- 有界类型推导会为常规声明保留有用的精确类型,并将异常深的尾部结构退化为 `JsonValue`;运行时 schema 强制执行在任意深度仍保持精确。 +- 原始工具仍可直接注册范围更广的 JSON Schema,但统一代码生成会把不受支持的 schema 视为未知类型,不会假装自己能够强制执行。 +- 运行时和编译期测试覆盖所有根类型、恰好匹配一个分支时的重叠/无匹配行为、原始 schema 的默认开放语义、显式开放方式、有损 JSON 值、类型推导、核心投影和动态投影中的深层嵌套、动态注册中 JSON 不可见的键,以及非普通 schema 数组。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-code-mode-result-card-completeness.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-20-code-mode-result-card-completeness.i18n.yaml new file mode 100644 index 0000000000..8453907b5c --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-20-code-mode-result-card-completeness.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-code-mode-result-card-completeness.md: 03c14cd780832fa03977dade2c7d14feb0399369 +2026-07-20-code-mode-result-card-completeness.zh.md: 45047cc5bcb8b74668702302077ff91fd3ff6bdc diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-code-mode-result-card-completeness.md b/.agents/notes/implemented/bug-fix/2026-07-20-code-mode-result-card-completeness.md new file mode 100644 index 0000000000..03c14cd780 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-20-code-mode-result-card-completeness.md @@ -0,0 +1,39 @@ +# Agent Note: Keep the Code Mode result card complete + +Status: implemented + +English | [中文](2026-07-20-code-mode-result-card-completeness.zh.md) + +## Problem + +The outer `run_code` tool persisted complete rendered content, but its editor presenter ignored that content and rebuilt the card body from a logs-only `presentationMeta` projection. A result-only run appeared correct because an empty presenter body let ACP and TUI fall back to `tool/result.content`. Once the program emitted a log, the presenter supplied non-empty content, that fallback stopped, and the returned value disappeared from the completed card. A spill policy's final head/tail preview was vulnerable to the same split ownership whenever captured logs made the stale projection non-empty. + +Nested Code calls never owned cards, so producing metadata for the outer call solely to reconstruct one incomplete card also obscured the intended one-card boundary. + +## Decision + +The canonical tool registry pipeline owns the final model-facing outer content. On success, the `run_code` output renderer renders captured logs followed by the return value or the explicit no-output marker. Runtime failures and pre-execution policy denials are normalized into error content by `ToolRegistry` without invoking that renderer. A post-execute block runs after successful rendering and replaces the result with error content; other post-execute policy and spill decisions may replace content before persistence. + +`run_code` omits `presentResult`. The established generic result fallback keeps the pending program title and renders the raw final `tool/result.content`; that durable, replayable, post-policy projection is the card's only result-content source. The host API proxy therefore omits a separate result view instead of serializing the same content in both `event.data.content` and `view.view.content`. The redundant logs-only `presentationMeta` projection remains removed. + +Nested dispatch remains unchanged. Calls marked by `exec.parent` emit bounded `tool/code-dispatch` diagnostics but no `tool/call` or `tool/result` surface cards, so one outer `run_code` invocation still produces exactly one card. + +## Testing + +Tool unit coverage drives logs-only, result-only, logs-plus-result, no-output, spilled-result, and failure outcomes through the canonical registry, then pins the durable content and absence of a result presenter. A host-mux regression uses a call-only presenter to prove the result frame carries raw content exactly once and no view. These cases prove stale metadata cannot replace final content without making the host duplicate that content. + +The keyless ACP and TUI Code Mode snapshots execute one outer program that performs two nested bash calls, logs `captured output`, and returns `CODE_ONE+CODE_TWO`. Both surfaces show one completed outer card containing both lines and no nested cards. + +## Alternatives considered + +**Append the return value to logs metadata.** Rejected because metadata would duplicate the renderer, need a second stable formatting contract for every JSON root, and still miss post-policy content replacement or spill previews. + +**Merge presenter metadata with `result.content`.** Rejected because the rendered content already contains the logs; merging would duplicate them and require brittle deduplication. + +**Forward `result.content` through a generic result presenter.** Rejected because the durable event already carries that content and ACP/TUI already have a generic raw-content fallback. The host mux serializes a tool-owned result view beside the event, so forwarding would duplicate the rendered content in one frame merely to recreate the fallback; the default worker alone admits a 64 MiB variable-payload budget before rendering. + +**Create one card per nested dispatch.** Rejected because intermediate values are intentionally execution-local and never model-facing. Multiple cards would expose an implementation trace instead of the single Code Mode operation the model and user invoked. + +## Consequences + +ACP and TUI display the same complete content the model receives and replay persists, including post-policy spill previews, through their generic result fallback. The host API retains the pending program title without duplicating the raw result in a separate view payload. New `run_code` results no longer carry the optional logs metadata, but this requires no session-format bump: existing records remain valid because presentation reads their durable rendered content. diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-code-mode-result-card-completeness.zh.md b/.agents/notes/implemented/bug-fix/2026-07-20-code-mode-result-card-completeness.zh.md new file mode 100644 index 0000000000..45047cc5bc --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-20-code-mode-result-card-completeness.zh.md @@ -0,0 +1,39 @@ +# Agent Note: 保证 Code Mode 结果卡片内容完整 + +Status: implemented + +[English](2026-07-20-code-mode-result-card-completeness.md) | 中文 + +## 问题 + +外层 `run_code` 工具会持久化完整的渲染内容,但编辑器的卡片展示逻辑忽略了这些内容,转而根据仅含日志的 `presentationMeta` 投影重新构建卡片正文。仅有结果的运行看似正确,是因为展示逻辑未提供正文时,ACP 和 TUI 会回退到 `tool/result.content`。只要程序输出一条日志,展示逻辑就会提供非空内容,回退随即停止,返回值便会从完成态卡片中消失。当已捕获的日志使陈旧投影变为非空时,输出落盘策略最终生成的头尾预览也会受到同一职责拆分的影响。 + +嵌套 Code 调用从不生成自己的卡片。因此,仅仅为了重建这一张不完整卡片而给外层调用生成元数据,还掩盖了每次外层调用只生成一张卡片的预期边界。 + +## 决策 + +规范的工具注册表流水线负责最终面向模型的外层内容。成功时,`run_code` 输出渲染器先渲染已捕获的日志,然后渲染返回值或显式的无输出标记。运行时失败和执行前策略拒绝由 `ToolRegistry` 归一化为错误内容,过程中不会调用该渲染器。Post-execute 阻断发生在成功渲染之后,并把结果替换为错误内容;其他 post-execute 策略与输出落盘决策可以在持久化之前替换内容。 + +`run_code` 不提供 `presentResult`。既有的通用结果回退机制会保留待完成的程序标题,并渲染原始的最终 `tool/result.content`;这一持久、可回放且经过 post-policy 处理的投影是卡片中结果内容的唯一来源。宿主 API 代理因此不提供单独的结果视图,而不会在 `event.data.content` 与 `view.view.content` 中重复序列化同一内容。冗余的仅含日志的 `presentationMeta` 投影继续保持移除状态。 + +嵌套分发保持不变。带有 `exec.parent` 标记的调用会发出有界的 `tool/code-dispatch` 诊断,但不会生成与 `tool/call` 或 `tool/result` 对应的界面卡片,因此一次外层 `run_code` 调用仍然只会生成一张卡片。 + +## 测试 + +工具单元测试通过规范注册表覆盖仅有日志、仅有结果、日志与结果并存、无输出、结果落盘和失败的结果,然后固定持久内容以及结果展示器不存在这一事实。宿主 mux 回归测试使用仅有调用的展示器,证明结果帧恰好携带一次原始内容,且不含视图。这些案例证明陈旧元数据无法替换最终内容,同时不会让宿主重复该内容。 + +无密钥的 ACP 与 TUI Code Mode 快照会执行一个外层程序:程序进行两次嵌套 bash 调用,记录 `captured output`,并返回 `CODE_ONE+CODE_TWO`。两个界面都只显示一张完成态外层卡片,其中包含这两行内容,且没有嵌套卡片。 + +## 备选方案 + +**把返回值追加到日志元数据:**不予采纳。元数据会与渲染器重复,并且需要为每一种 JSON 根另行维护稳定的格式化契约;post-policy 内容替换或输出落盘预览仍然会被遗漏。 + +**把展示元数据与 `result.content` 合并:**不予采纳。渲染内容已经包含日志;合并会造成重复,还需要依赖脆弱的去重逻辑。 + +**通过通用结果展示器转发 `result.content`:**不予采纳。持久事件已经携带该内容,ACP 和 TUI 也已有通用的原始内容回退机制。宿主 mux 会在事件旁序列化工具拥有的结果视图,因此转发仅仅是为了重建该回退机制,却会在一个帧中重复渲染内容;仅默认 worker 在渲染前允许 64 MiB 的可变载荷预算。 + +**为每次嵌套分发创建一张卡片:**不予采纳。中间值有意只存在于执行期间,永远不面向模型。多张卡片会暴露实现轨迹,而不是模型与用户调用的单次 Code Mode 操作。 + +## 影响 + +ACP 和 TUI 通过通用结果回退机制显示与模型接收及回放持久化相同的完整内容,其中包括 post-policy 输出落盘预览。宿主 API 保留待完成的程序标题,同时不在单独的视图负载中重复原始结果。新的 `run_code` 结果不再携带可选的日志元数据,但无需提升会话格式版本:现有记录仍然有效,因为展示逻辑会读取其中持久化的渲染内容。 diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.md b/.agents/notes/implemented/feature/2026-06-15-code-mode.md index 329eaf2d0a..ff052ac1fe 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.md +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.md @@ -20,6 +20,8 @@ Three decisions, each elaborated in its own section below: 2. **Code execution is a capability seam** — `packages/code-runtime/` contains the interface package `@deepseek-ai/dsh-code-runtime`, which owns `ctx.codeRuntime` ([capability seams](../architecture/2026-06-13-capability-seams.md); consumer = `dsh-tools`, with core-consumes-a-seam precedent in `agent-loop` → `dsh-llm`). The runtime knows nothing about tools: it is handed a program and named async bindings, runs the program, and reports `{ value, logs, error? }`. Language and substrate are backend properties, so a future Python or container backend is another implementation package, not a redesign. 3. **The shipped implementation is `@deepseek-ai/dsh-code-runtime-worker`**: one fresh Node worker thread per run, executing the model's TypeScript after type-strip, with bindings bridged over the message port, an empty environment, configurable heap/output/time caps, and hard termination. Its trust posture is bash-equivalent by design — no unsafe-acknowledgement flags — because the harness already ships `dsh-bash-local`, which executes arbitrary model-written shell commands with strictly *more* ambient authority. +This note owns Code Mode's presentation, composition, isolation, and settlement foundation. The later [typed tool-return Agent Note](2026-07-20-code-mode-typed-tool-returns.md) owns the generated output map, canonical binding values, `ToolCallError`, and the lossless outer-output boundary. + ### The registry owns the mode `ToolRegistry` gains a schemastery-validated config (`static Config`), its first: `mode: 'native' | 'code' | 'both'`, default `'native'`. A deployment flips it from `cordis.yml` (`tools: { mode: code }`) — no code edit, per the no-hardcoded-tunables convention. @@ -38,15 +40,15 @@ Three decisions, each elaborated in its own section below: Under `'code'` and `'both'` the registry owns `run_code` as a reserved presentation transport with one required parameter, `{ code: string }`. It is represented by a normal `ToolDefinition` for dispatch but stays outside the filterable capability layers, so restrictions cannot accidentally remove Code Mode's only entry point. Calls traverse the complete tool pipeline — `tools/pre-execute` → monotonic guards → `tools/execute` around dispatch → `tools/post-execute` → immutable `tools/result` notification — exactly like native calls; a permission plugin can inspect the program text before it runs, and final-result observers see the normalized outer outcome. Its `execute(args, exec)`: -1. **Build bindings.** One run-scoped signal follows outer cancellation and is aborted whenever the run settles. Each visible tool binding JSON-normalizes its arguments—rejecting lossy values before dispatch—waits on the serialization queue, executes with a deterministic call id and the outer token as `parent`, defers returned contexts through the outer execution, and logs `tool/code-dispatch`. Successful text becomes a string and non-text blocks become placeholders; tool errors reject the binding promise. Every sub-call retains its own immutable execution identity and traverses the full tool pipeline. +1. **Build bindings.** One run-scoped signal follows outer cancellation and is aborted whenever the run settles. Each visible tool binding snapshots lossless-JSON arguments, waits on the serialization queue, executes with a deterministic call id and the outer token as `parent`, defers returned contexts through the outer execution, and logs `tool/code-dispatch`. Success returns the tool's final canonical JSON value; failure becomes the program-visible `ToolCallError`. Every sub-call retains its own immutable execution identity and traverses the full tool pipeline. 2. **Runs the program**: `ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`. The runtime receives the run-scoped signal, not only the caller's outer signal, so any way the outer run settles also aborts work inside the runtime. -3. **Settle after quiescence.** When the runtime settles, the bridge aborts outstanding work and drains the dispatch queue before returning. Success returns captured output and presentation metadata. A runtime failure becomes `CodeRunFailedError`; backend rejection uses the registry's normal error boundary. Both produce structured error results, and no sub-call can append after `run_code` settles. +3. **Settle after quiescence.** When the runtime settles, the bridge aborts outstanding work and drains the dispatch queue before returning. Success returns captured logs and the completion value as canonical output; the registry renders that value into durable `tool/result.content`, which the result card reads directly. A runtime failure becomes `CodeRunFailedError`; backend rejection uses the registry's normal error boundary. Both produce structured error results, and no sub-call can append after `run_code` settles. **Sub-call contexts are deferred through the parent.** Injecting inside `run_code` would break parent call/result adjacency, so `ToolRunContext.deferContext()` collects every sub-result `additionalContexts` entry in dispatch order. The registry carries that array even when the program later throws, and the loop appends each entry only after the outer result and every sibling result in the step. An outer post-execute block discards tool-deferred entries and exposes only contexts explicitly attached by the blocking decision. **Concurrency is serialized.** Each run owns a dispatch queue, so even `Promise.all` executes tool calls in submission order. Settlement abandons queued calls that have not started. Parallelism requires per-tool concurrency-safety metadata. -**Presentation.** `run_code`'s render intent is decided here per the [render-intent Agent Note](../architecture/2026-07-02-tool-render-intent-union.md): `presentCall` → a `generic` card, `kind: 'execute'`, title = the program text, `rawInput` = the same program text; `presentResult` → a `generic` card whose content is the captured output (from `meta`). The program is the title because ACP execute cards reliably render that field while some clients omit body and raw-input content. This is not a `terminal` card: that card's semantics are "a shell command in a working directory", which a program is not. +**Presentation.** `run_code`'s render intent is decided here per the [render-intent Agent Note](../architecture/2026-07-02-tool-render-intent-union.md): `presentCall` creates a `generic` card with `kind: 'execute'`, the program text as its title, and the same program text as `rawInput`; `run_code` intentionally declares no `presentResult`, so ACP and TUI complete that card through their generic raw-content fallback using the final durable `tool/result.content`, including captured logs plus the returned value, failure, or post-policy spill preview. The program is the title because ACP execute cards reliably render that field while some clients omit body and raw-input content. This is not a `terminal` card: that card's semantics are "a shell command in a working directory", which a program is not. See the [result-card completeness note](../bug-fix/2026-07-20-code-mode-result-card-completeness.md). ### Observability: `tool/code-dispatch` @@ -57,10 +59,9 @@ Each sub-dispatch appends a log-only `tool/code-dispatch` event containing paren `packages/code-runtime/code-runtime/` — `@deepseek-ai/dsh-code-runtime`, depending only on `cordis`. An abstract `CodeRuntime extends Service` (`super(ctx, 'codeRuntime')`) plus the vocabulary: - `CodeRunRequest = { program: string; bindings: CodeBindingNamespace[]; signal?: AbortSignal }` -- `CodeBindingNamespace = { global: string; functions: Record Promise> }` — the runtime exposes each namespace as a global object of async functions inside the program; binding arguments and resolutions must be structured-cloneable (a runtime may cross a serialization boundary; ours does). -- `CodeRunResult = { value?: unknown; logs: CodeLogEntry[]; error?: CodeRunFailure }` — program execution outcomes, including exception, timeout, abort, and worker exit, resolve as the `error` field. `run()` may reject only for caller/seam misuse (for example a duplicate binding namespace); consumers still contain a non-conforming backend rejection at their own error boundary. -- `CodeLogEntry = { source: 'console' | 'stdout' | 'stderr'; level?: 'log' | 'info' | 'warn' | 'error' | 'debug'; text: string }` -- `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit'; message: string }` — orthogonal outcomes reported independently per [defensive patterns](../../../../docs/defensive-patterns.md); a timed-out run is not an exception, an abort is not a timeout. +- `CodeBindingNamespace = { global: string; functions: Record Promise>; errorClass?: { name: string; memberNameProperty: string } }` — the runtime exposes each namespace as a global object of async functions inside the program; the optional descriptor asks the runtime to inject a real program-visible rejection class without teaching the seam consumer-specific names. `CodeJsonValue` is this dependency-light seam's structural lossless-JSON type, so binding arguments and resolutions cross the implementation's serialization boundary whole. +- `CodeRunResult = { value?: CodeJsonValue; logs: string[]; error?: CodeRunFailure }` — program execution outcomes resolve as the `error` field. `run()` may reject only for caller/seam misuse (for example a duplicate binding namespace); consumers still contain a non-conforming backend rejection at their own error boundary. +- `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' | 'invalid-output' | 'output-limit'; message: string }` — orthogonal outcomes reported independently per [defensive patterns](../../../../docs/defensive-patterns.md); a timed-out run is not an exception, an abort is not a timeout, a lossy completion is not an overflow, and a substrate exit is none of them. - Two readonly backend descriptors, informational not gating: `language` (what the program must be written in — `'typescript'` for the shipped backend; a Python backend would say so, and pair with its own SDK generator on the presentation side) and `isolation` (`'worker-thread'` for the shipped backend; `'process'`, `'container'`, … for future ones). `dsh-tools` requires `language === 'typescript'` in the MVP — its codegen emits TS — and fails the assembly loudly otherwise, the same misconfiguration idiom as `toolOrder` violations (as when `mode` is non-native with no `ctx.codeRuntime` loaded at all). Requests contain every runtime input; implementations own validated timeout and cap defaults. The registry looks up the optional runtime only when Code Mode is assembled, so native mode does not depend on one. Missing or language-incompatible runtimes fail loudly. Alternate substrates or languages can replace the implementation behind the same seam, paired with the appropriate SDK generator. @@ -71,9 +72,9 @@ Requests contain every runtime input; implementations own validated timeout and 1. **Type-strip host-side** with Node's built-in `stripTypeScriptTypes` (`node:module`; present across the repo's whole engines range, `^22.19.0 || >=24.0.0`, and position-preserving, so runtime error line numbers match the model's source). Strip-only mode rejects non-erasable syntax (`enum`, namespaces) — that rejection returns as `error.kind: 'exception'` with Node's message, the SDK instructions say "erasable TypeScript only", and the model self-corrects like any other program error. A syntax-level failure never spawns a worker. 2. **Spawn one fresh `Worker` per run** from the package's own bootstrap module: `env: {}` (truly empty — stronger than the scrubbed-env rule for spawned commands), `resourceLimits` from config, `stdout`/`stderr` captured into `logs` rather than inherited. No pooling and no cross-run state: a program's world dies with its worker, which keeps runs reconstructable from the log alone and makes state bleed unrepresentable. -3. **Execute** in the bootstrap: the stripped program becomes the body of an `AsyncFunction` whose parameters are the binding globals and a capturing `console` shim, so top-level `await` and `return` work and the program's completion value is the run's `value` (structured-cloneable values cross as-is; anything else is replaced by its `util.inspect` rendering, documented). +3. **Execute** in the bootstrap: the stripped program becomes the body of an `AsyncFunction` whose parameters are the binding globals, any consumer-declared rejection classes, and a capturing `console` shim, so top-level `await` and `return` work. Code Mode declares `ToolCallError` with member property `toolName`; the runtime materializes that real constructor without hardcoding tools. A lossless JSON completion crosses exactly; `undefined` remains absence, a lossy value is `invalid-output`, and an oversized outer result is `output-limit` rather than an inspected-string substitute. 4. **Bridge bindings over the message port**: each binding function in the worker posts `{ id, global, name, args }` and awaits the reply; the host validates the name against the request's bindings, invokes, and replies `{ id, ok, value }` or `{ id, ok: false, message }` (a host-side binding rejection becomes a program-side rejection). The worker-side namespace objects are built null-prototype via `defineProperty`, so a binding named `__proto__`, `constructor`, or `toString` is an ordinary own property, not a prototype collision. Unknown names, duplicate ids, and post-settlement messages are rejected or ignored — the port protocol assumes a hostile peer, because the peer runs model code. -5. **Enforce independent budgets.** `computeMs` meters worker busy time, allowing slow awaited tools without excusing a hot loop. `maxWallMs` bounds total elapsed time, including unresolved waits. Expiry, cancellation, and completion terminate the worker. Heap exits and truncation are reported explicitly; compute, wall, heap, log, and return-value caps are validated configuration. +5. **Enforce independent budgets.** `computeMs` meters worker busy time, allowing slow awaited tools without excusing a hot loop. `maxWallMs` bounds total elapsed time, including unresolved waits. `maxOutputBytes` bounds only the combined serialized outer logs, completion, or diagnostic; intermediate binding values have no byte cap. Expiry, cancellation, and completion terminate the worker, and heap exits or outer overflow are explicit failures. 6. **Dispose to quiescence**: the service's own disposal terminates in-flight workers and *awaits* their exits before resolving, per [defensive patterns](../../../../docs/defensive-patterns.md). ### Trust posture @@ -90,7 +91,7 @@ Deployments switching to `'code'` must update any native-only `toolOrder`. Assem ## Testing -- **Worker runtime:** Real-worker tests cover output and value capture, failure kinds, compute and wall budgets, hostile binding traffic, empty environment, structured-clone fallback, output caps, and disposal to quiescence. A built-package test runs the worker entry under plain Node. +- **Worker runtime:** Real-worker tests cover typed binding values and failures, every lossless JSON completion root, invalid and over-limit output, exact combined ledger boundaries, compute and wall budgets, hostile binding traffic, empty environment, and disposal to quiescence. A built-package test runs the worker entry under plain Node. - **Registry integration:** Tests cover code generation, all presentation modes, reserved-name and restriction rules, scoped visibility, authoritative assembly rewrites, `toolOrder`, runtime compatibility failures, full-pipeline sub-dispatch, parent-token correlation, serialization, cancellation and queue drain, JSON normalization, error propagation, log events, ordered context deferral across successful and failed programs, outer-block suppression, and HMR cleanup. - **With-key e2e:** A real model composes two bash calls in one program; another discovers nested workspace instructions through a Code Mode fs dispatch. The tests verify collapsed request headers, correlated dispatch events, resulting files, deferred context, and model behavior. - **Snapshot:** The `code-mode-turn`, `both-mode-turn`, and `code-mode-workspace-context` fixtures pin SDK text, header tool lists, dispatch events, deferred context, and result cards. @@ -123,7 +124,7 @@ Deployments switching to `'code'` must update any native-only `toolOrder`. Assem **Registry scope growth.** `dsh-tools` absorbs codegen, a tool, a bridge, and an event. Contained by module boundaries inside the package (`ts-types.ts`, `code-mode.ts` beside `schema.ts`/`json-schema.ts`/`presentation.ts`) and by the seam: everything substrate-shaped lives behind `ctx.codeRuntime`. -**Structured-clone values can exceed JSON.** Tool bindings therefore JSON-normalize arguments before dispatch, ensuring every executed call can be logged. The lower-level runtime keeps its wider port contract, while stricter consumers validate at their boundary. Non-text sub-results become placeholders. +**Large lossless JSON values can exhaust memory.** Tool bindings snapshot lossless JSON before dispatch and return canonical JSON resolutions whole. The runtime validates both sides of the worker port and applies no per-binding byte cap; structured-clone cost and process or worker memory are the practical bounds. The combined outer-output ledger for logs, the completion value, and a failure diagnostic is the only byte-capped boundary. **Serialized-only sub-dispatch.** `Promise.all` gains no wall-clock parallelism yet, only fewer round-trips; models may over-expect. The instructions state it; lifting it is tied to the same concurrency-safety metadata the native parallel-dispatch TODO needs. 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 08edfae386..ffff38e6b4 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 replace and restore the required `exec.signal` before doing so but cannot remove it, 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 replace and restore the required `exec.signal` before doing so but cannot remove it, and receives the already-normalized canonical success/failure result of a thrown or unknown tool; a wrapper-authored success short-circuits dispatch and 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-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..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,9 +30,9 @@ 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 `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/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml new file mode 100644 index 0000000000..0f1bead590 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.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-code-mode-typed-tool-returns.md: 29f139a7e965de3a374d195ecc205210e6ae7e93 +2026-07-20-code-mode-typed-tool-returns.zh.md: 431c0b1717c6783771255ce8291c241f8f92c30b diff --git a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md new file mode 100644 index 0000000000..29f139a7e9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md @@ -0,0 +1,112 @@ +# Agent Note: Typed tool returns in Code Mode + +Status: implemented + +English | [中文](2026-07-20-code-mode-typed-tool-returns.zh.md) + +## Problem + +Code Mode originally projected each nested tool result back from `ContentBlock[]` into one string. That preserved the human-readable Native surface but erased the canonical result the tool had already produced: programs had to scrape task ids and dynamic mount ids from prose, structured search and workflow results lost their shape, and non-text blocks became placeholders. The generated SDK could describe arguments but could only promise `Promise` regardless of the tool's real output. + +The runtime also treated binding values and the final program value as presentation data. Separate log and completion caps could replace an oversized or non-cloneable completion with inspected text even though intermediate values do not enter model context. That made programmatic composition lossy and confused the memory boundary with the prompt boundary. + +The [canonical tool-output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) establishes one validated execution-time value and a separate Native renderer. Code Mode should consume that value directly, preserve it across the worker boundary, and bound only the final output the program deliberately returns to the model. + +## Decision + +Code Mode is a typed projection of the visible tool registry. Each successful binding resolves to the final canonical `JsonValue` after post-execute policy, while a failed binding rejects with a real `ToolCallError`. Intermediate values remain inside the run and cross the worker boundary whole. Only the outer `run_code` logs, completion value, or failure diagnostic enter the configurable output ledger and the model-facing spill pipeline. + +This note owns the return and failure contract layered on the original [Code Mode foundation](2026-06-15-code-mode.md). The unified schema vocabulary is owned by the [JSON-value schema DSL note](../architecture/2026-07-20-unified-json-value-schema-dsl.md), and Native rendering and policy projection remain owned by the canonical-output note. + +### Generated SDK + +At each prompt assembly the registry projects every visible tool's parameter schema and detached canonical output schema into one deterministic declaration: + +```ts ignore-check +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } + +interface ToolArgsMap { + // one exact inferred entry per visible tool +} + +interface ToolOutputMap { + // one exact inferred entry per visible tool +} + +type ToolName = keyof ToolOutputMap + +declare class ToolCallError extends Error { + readonly name: 'ToolCallError' + readonly toolName: ToolName +} + +declare const tools: { + [K in ToolName]: (args: ToolArgsMap[K]) => Promise +} +``` + +`jsonSchemaToTs()` covers every supported unified-schema node: object, array, string, number, integer, boolean, null, unconstrained JSON, scalar `enum` and `const`, and `oneOf`. Unsupported raw constructs degrade to `unknown` during prompt generation rather than breaking assembly. Tool names retain their exact keys, including names that require quoted access. + +### Binding values and failures + +Before dispatch the bridge snapshots binding arguments as lossless JSON and snapshots the detached value again for an independent durable summary event. Host-side detachment, immutable execution, and output-schema projection all use iterative traversals rather than nested structured clone or recursive freezing. `undefined`, non-finite numbers, `-0`, sparse arrays, cycles, functions, and exotic objects reject that call before the tool runs. Successful dispatch returns `ToolExecutionResult.value`; Native `content`, metadata, and internal error information do not cross to the program. + +Code Mode declares its rejection capability on the runtime request as `{ name: "ToolCallError", memberNameProperty: "toolName" }`. The runtime seam treats those names as data: the worker materializes and injects the actual constructor used for `tools` binding failures, so `error instanceof ToolCallError` works without making a generic runtime know about tools. The worker constructs failures and defines their public fields through module-captured error and property-definition intrinsics plus null-prototype descriptors, so model mutations cannot replace the promised rejection with a worker failure. The error has the standard `Error` message plus the exact `toolName`; it deliberately omits `ToolFailure.info`, error codes, and Native content. This is an exception contract for control flow, not a failure union for programmatic classification. + +Binding arguments and resolutions are revalidated as lossless JSON on both sides of the hostile worker protocol and have no byte cap. Before crossing through structured clone, each detached value is encoded as a flat pre-order token stream whose transport nesting is bounded; the receiver rebuilds it iteratively. Valid application nesting therefore has neither a JavaScript call-stack depth cap nor a platform-specific nested structured-clone limit. At module initialization the worker captures its own realm's `Array.prototype` and `Object.prototype` identities, the native function-source intrinsic used only to recognize foreign-realm plain-container prototypes, and every structural and metering intrinsic used by the JSON boundary. Property writes use null-prototype descriptors, while private array and set operations invoke captured methods without consulting mutable global or prototype slots. Model code can therefore replace helpers such as `Object.keys`, `Array.isArray`, collection methods, string methods, or `Buffer.byteLength`, rewrite intrinsic-prototype constructor slots, or add descriptor-shaped fields to `Object.prototype` without changing validation, wire transport, or byte accounting. The foreign-realm native function-source check still rejects user-authored constructors that imitate `Object` or `Array`. The dependency-light runtime seam names its structural equivalent `CodeJsonValue` so it need not depend on the session-owned canonical type; the generated SDK and tool API use `JsonValue`. Intermediate values are not prompt-truncated, context-spilled, or persisted. This preserves full acquired search, workflow, task, filesystem, and MCP values for programmatic filtering while leaving provider and executor acquisition limits truthful. + +### Outer result and output ledger + +The runtime accepts an exact lossless JSON completion of any root. Returning `undefined` omits the completion; returning `null` is an explicit result. `run_code` exposes the canonical outer value `{ logs: string[], result?: JsonValue }`. Its Native renderer emits logs first, renders a string result raw, and renders every other JSON root with an iterative pretty printer. Total indentation is capped at ten characters and deeper subtrees remain compact, preserving the established shallow text while keeping traversal stack-safe and formatted size linear in the canonical JSON size. + +`WorkerCodeRuntime` replaces the former independent log and value caps with configurable `maxOutputBytes`, defaulting to `67_108_864` bytes. The worker charges captured logs by their exact JSON-string serialization and preflights the detached completion or program exception against the remaining combined budget before posting a terminal message. A giant thrown string or stack therefore crosses the worker port only as the fixed `output-limit` diagnostic. The host repeats the hostile-peer ledger for forged traffic and native pipe writes the worker cannot observe. Fixed `CodeRunResult` field names, braces, the bounded error-kind tag, and later presentation whitespace are deliberately outside this variable-payload ledger. Neither stage materializes an over-limit serialized completion. A result at or below the cap is exact. A completion that cannot survive lossless JSON snapshotting fails as `invalid-output`; a value, diagnostic, or combined outcome over the cap fails as `output-limit` rather than becoming inspected or truncated text. + +Logs stream eagerly so a terminated run can retain output already admitted. Native stdout and stderr writes that bypass the worker's patched stream slots use independent pipes, so terminal settlement continues bounded capture until worker termination completes before materializing the result. When the cap is crossed, the runtime returns an explicit bounded failure with the fitting captured prefix. That outer result then traverses the ordinary `run_code` rendering and spill policy, which may save the captured text and expose its configured head/tail preview. The spill layer cannot recover bytes the runtime rejected beyond the hard cap. + +Compute time, wall time, worker heap, cancellation, and fresh-worker isolation remain independent limits. The outer ledger never charges intermediate bindings, so snapshotting, flat-wire encoding and decoding, structured-clone cost, and available process or worker memory are their practical bounds. + +### Typed handles and lifetime + +Background producers return a typed canonical handle such as `{ kind: 'background', taskId }` while retaining their established Native sentence. A pre-aborted background call remains a failure because successful output promises an id and no task was created. After `ctx.tasks.start()` publishes the id, task-owned cancellation governs the work: settlement or later cancellation of the enclosing `run_code` call does not kill it. A later program can pass the returned id to `task_output`, and `task_kill`, owner disposal, or service teardown owns cancellation. Foreground execution remains coupled to the call signal. The task lifetime contract is owned by the [background task runtime note](../architecture/2026-06-20-generic-long-running-tool-runtime.md). + +Dynamic Cordis mounting follows the same rule: `cordis_mount` returns `{ id, pluginName, state, provides, waitingFor }`, so a program can read `mounted.id`, inspect active or pending state, and pass that id to `cordis_unmount` without parsing the stable Native sentence. + +### Persistence, metadata, and spill + +Nested dispatch keeps the existing bounded `tool/code-dispatch.resultSummary` for diagnostics but does not persist canonical values. `tool/result` continues to persist only rendered content, error, and optional metadata. This is deliberately not a session-format change, so `SESSION_FORMAT_VERSION` remains unchanged and replay cannot recreate intermediate program values. + +The opaque `exec.parent` token marks nested calls. Presentation metadata and generic or tool-owned spill projections skip those calls because they have no direct result card and their canonical values never enter context. The outer `run_code` call alone produces one card and may spill its final post-policy presentation; `run_code` intentionally declares neither a result presenter nor presentation metadata, so ACP and TUI complete the card through their generic raw-content fallback using durable `tool/result.content`. + +## Testing + +Compile-time and snapshot tests pin exact `ToolArgsMap`, `ToolOutputMap`, `ToolName`, schema-to-TypeScript coverage, and exotic names. Registry and real-worker tests cover scalar, array, object, and null values; raw string rendering; absent `undefined`; consumer-declared real rejection classes, including `ToolCallError`; invalid arguments and completions, including intrinsic-looking forged prototypes; model-mutated JSON-boundary globals, prototype methods, constructor slots, and inherited descriptor fields; typed binding failures after those mutations; large uncapped intermediate bindings; nested spill suppression; exact and over-limit 64 MiB accounting; combined logs/value/diagnostic accounting; giant thrown stacks; bounded failure spill; hostile forged traffic; and built-package execution. + +Keyless real-worker integration tests pin the two handle workflows that prose results could not safely support. A background bash call returns its task id, the outer run settles, and a later run polls that id to completion; separate cases prove pre-abort creates no task, post-publication call abort preserves the task, foreground execution stays signal-coupled, and `task_kill` owns cancellation. A Cordis program reads an active or pending mount's id and `waitingFor` fields directly, unmounts by that id, and confirms removal without parsing rendered text. + +## Alternatives considered + +**Return Native text plus optional JSON.** Rejected because the program would have two competing success contracts and would still need tool-specific parsing rules when the optional value is absent. Canonical value is the API; Native content is its presentation. + +**Expose a success/failure union from every binding.** Rejected because failure has no stable programmatic taxonomy. Rejections preserve ordinary `try`/`catch` control flow and expose only the tool name and human-readable message. + +**Cap each intermediate binding.** Rejected because intermediate values are not placed in model context and arbitrary truncation would corrupt programmatic composition. The producer's acquisition contract and process memory remain explicit boundaries. + +**Silently inspect or truncate an oversized completion.** Rejected because changing a JSON value into a string is lossy and type-incorrect. The explicit `output-limit` failure lets the model choose a smaller result, while the retained logs and diagnostic can still use normal outer spill. + +## Consequences + +Code programs can compose tools through stable values instead of reverse-engineering Native prose. Native and Both Mode retain their existing text and editor presentation, while Code Mode receives output-schema types and exact runtime JSON. Tool authors must treat the canonical value as their programmatic API and put display-only formatting in the renderer. + +The worker performs bounded-depth flat-wire transport and lossless validation but does not make intermediate values cheap or durable. Outer overflow is an explicit failed run, and error handling remains intentionally human-guided rather than a versioned code union. + +## Known Limitations and Deferred Work + +- Subagent and workflow caller-defined structured outputs remain object-rooted through consumer-level guards even though tool outputs may use any JSON root. +- Post-execute has separate value and presentation projections; replacing content is not a confidentiality mechanism, so policy must block or replace the value to hide it from programmatic callers. +- Intermediate canonical values are execution-local and unavailable to replay because durable events persist only presentation and bounded summaries. +- Intermediate values have no byte cap and can exhaust process or worker memory through retention, flat-wire copies, or structured-clone cost. +- The 64 MiB hard cap applies only to the outer variable payloads, excluding fixed result-envelope syntax and presentation whitespace; spill cannot recover bytes rejected beyond that cap. +- Provider or executor acquisition limits may already have discarded source data before a canonical value reaches Code Mode. +- Unsupported MCP output schemas fall back to `JsonValue`; richer Native multimedia projection is deferred. +- There is one result card per outer `run_code`, never per nested call. +- Code failures expose `ToolCallError` message and tool name only, without a programmatic error-code union. diff --git a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md new file mode 100644 index 0000000000..431c0b1717 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md @@ -0,0 +1,112 @@ +# Agent Note:Code Mode 的类型化工具返回值 + +Status: implemented + +[English](2026-07-20-code-mode-typed-tool-returns.md) | 中文 + +## 问题 + +Code Mode 过去会把每个嵌套工具的结果从 `ContentBlock[]` 重新投影为一个字符串。这样虽然保留了适合人类阅读的 Native 接口,却丢失了工具已经生成的规范结果:程序只能从自然语言中提取 task id 和动态挂载 id;结构化搜索与工作流结果失去原有形态;非文本块则变为占位符。生成的 SDK 可以描述参数,却无论工具实际输出为何都只能承诺 `Promise`。 + +运行时还把绑定值和程序最终返回值当作展示数据。日志和完成值分别设置上限,导致过大或无法克隆的完成值可能被替换为检查格式化后的文本,而中间值本来就不会进入模型上下文。这种设计使程序化组合产生信息损失,也混淆了内存边界与提示词边界。 + +[规范工具输出契约](../architecture/2026-07-20-canonical-tool-output-contract.md)确立了单一、经过校验的执行期值,并将 Native 渲染器与之分离。Code Mode 应直接消费该值,在跨越 worker 边界时完整保留它,并且只限制程序有意返回给模型的最终输出。 + +## 决策 + +Code Mode 是可见工具注册表的类型化投影。每个成功的绑定调用都会解析为 post-execute 策略处理后的最终规范 `JsonValue`,失败的绑定调用则以真正的 `ToolCallError` reject。中间值只存在于本次运行中,并完整跨越 worker 边界。只有外层 `run_code` 的日志、完成值或失败诊断会进入可配置的输出账本以及面向模型的输出落盘流水线。 + +本文档定义叠加在原始 [Code Mode 基础](2026-06-15-code-mode.md)之上的返回值与失败契约。统一 schema 词汇由 [JSON 值 schema DSL Agent Note](../architecture/2026-07-20-unified-json-value-schema-dsl.md)定义;Native 渲染与策略投影仍由规范输出 Agent Note 定义。 + +### 生成的 SDK + +每次组装提示词时,注册表都会把每个可见工具的参数 schema 及其分离的规范输出 schema 投影为一份确定性声明: + +```ts ignore-check +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } + +interface ToolArgsMap { + // one exact inferred entry per visible tool +} + +interface ToolOutputMap { + // one exact inferred entry per visible tool +} + +type ToolName = keyof ToolOutputMap + +declare class ToolCallError extends Error { + readonly name: 'ToolCallError' + readonly toolName: ToolName +} + +declare const tools: { + [K in ToolName]: (args: ToolArgsMap[K]) => Promise +} +``` + +`jsonSchemaToTs()` 覆盖统一 schema 支持的所有节点:对象、数组、字符串、数字、整数、布尔值、null、无约束 JSON、标量 `enum` 与 `const`,以及 `oneOf`。提示词生成期间,不支持的原始结构会降级为 `unknown`,而不会导致组装失败。工具名会保留精确键名,包括必须使用引号访问的名称。 + +### 绑定值与失败 + +分发前,桥接层会把绑定参数快照为无损 JSON,再对分离后的值生成一次快照,供独立的持久摘要事件使用。宿主侧的值分离、执行数据的不可变处理与输出 schema 投影均采用迭代遍历,而不使用嵌套结构化克隆或递归冻结。`undefined`、非有限数、`-0`、稀疏数组、循环引用、函数和非普通对象都会使该调用在工具运行前 reject。成功分发会返回 `ToolExecutionResult.value`;Native `content`、元数据和内部错误信息不会传入程序。 + +Code Mode 通过运行时请求中的 `{ name: "ToolCallError", memberNameProperty: "toolName" }` 声明其 reject 异常能力。运行时 seam 只把这些名称视为数据:worker 会动态生成并注入真正用于 `tools` 绑定失败的构造函数,因此无需让通用运行时了解工具,`error instanceof ToolCallError` 也能成立。worker 使用模块初始化时捕获的 Error 构造函数与属性定义内建方法,配合原型为 null 的属性描述符,构造失败对象并定义其公开字段,因此模型代码的修改不会把契约承诺的 reject 变成 worker 失败。该错误包含标准的 `Error` 消息和确切的 `toolName`,并有意省略 `ToolFailure.info`、错误代码与 Native 内容。这是一项用于控制流的异常契约,而不是供程序分类的失败联合。 + +绑定参数与绑定返回值会在不可信 worker 协议的两端重新校验为无损 JSON,且不设字节上限。每个分离后的值在通过结构化克隆跨越边界前,都会编码为扁平的前序 token 流,其传输结构的嵌套深度有界;接收方再以迭代方式重建该值。因此,有效应用数据的嵌套深度既不受 JavaScript 调用栈深度上限限制,也不受特定平台对嵌套结构化克隆施加的上限限制。模块初始化时,worker 会捕获自身 JavaScript 运行域中 `Array.prototype` 和 `Object.prototype` 的引用、仅用于识别其他运行域普通容器原型的原生函数源码内建方法,以及 JSON 边界用于结构处理和计量的全部内建方法。属性写入使用原型为 null 的属性描述符;内部的数组与集合操作直接调用捕获的方法,不会访问可变的全局或原型槽位。因此,即使模型代码替换 `Object.keys`、`Array.isArray`、集合方法、字符串方法或 `Buffer.byteLength` 等辅助方法,重写内建原型的构造函数槽位,或向 `Object.prototype` 添加形如属性描述符的字段,也不会改变校验、协议传输或字节计量。面向其他运行域的原生函数源码检查仍会拒绝由用户编写、冒充 `Object` 或 `Array` 的构造函数。为保持依赖轻量,运行时 seam 将结构等价类型命名为 `CodeJsonValue`,从而无需依赖会话侧拥有的规范类型;生成的 SDK 和工具 API 则使用 `JsonValue`。这些值不会经过提示词截断、上下文输出落盘或持久化。因此,程序可以完整筛选已经采集的搜索、工作流、任务、文件系统与 MCP 值,同时提供方和执行器的采集上限仍会实际生效。 + +### 外层结果与输出账本 + +运行时接受以任意 JSON 类型为根的精确无损完成值。返回 `undefined` 表示省略完成值;返回 `null` 则是显式结果。`run_code` 暴露规范外层值 `{ logs: string[], result?: JsonValue }`。其 Native 渲染器先输出日志;字符串结果保持原文,其他所有 JSON 根值则使用迭代式美化渲染器。总缩进长度上限为 10 个字符,更深的子树保持紧凑格式,既保留既有的浅层文本,又确保遍历不受调用栈深度限制,且格式化输出大小与规范 JSON 大小呈线性关系。 + +`WorkerCodeRuntime` 以可配置的 `maxOutputBytes` 取代彼此独立的日志与值上限,默认值为 `67_108_864` 字节。worker 会将已捕获日志序列化为 JSON 字符串后的精确字节数计入账本,并在发送终态消息前,根据组合账本的剩余额度预检分离后的完成值或程序异常。因此,即使抛出的字符串或堆栈极大,通过 worker 端口的也只会是固定的 `output-limit` 诊断。宿主侧会针对伪造流量以及 worker 无法观察的原生管道写入,重复执行这套不可信对端计账。固定的 `CodeRunResult` 字段名、花括号、有界的错误类型标签及后续展示空白有意不计入这份可变负载账本。这两个阶段都不会实际生成超出上限的完成值序列化结果。结果不超过上限时会保持精确。完成值无法通过无损 JSON 快照时,以 `invalid-output` 失败;值、诊断或包含日志的组合结果超过上限时,以 `output-limit` 失败,而不会变成检查格式化后或截断的文本。 + +日志会在产生时立即流出,因此运行被终止时仍可保留已经纳入额度的输出。绕过 worker 中已改写流写入入口的原生 stdout 和 stderr 写入会经由彼此独立的管道传输,因此运行时在终态结算期间仍会继续在上限内捕获输出,直至 worker 完全终止,然后才组装结果。超过上限后,运行时会返回一个显式的有界失败,并携带可容纳的已捕获前缀。该外层结果随后通过普通的 `run_code` 渲染与输出落盘策略;策略可以保存已捕获的文本,并暴露其配置指定的头尾预览。输出落盘层无法恢复运行时在硬上限之外拒绝的字节。 + +计算时间、墙钟时间、worker 堆内存、取消和每次运行使用全新 worker 的隔离仍是互相独立的限制。外层账本从不计入中间绑定值,因此生成快照、扁平协议格式的编码与解码、结构化克隆开销,以及进程或 worker 的可用内存构成了这些值的实际边界。 + +### 类型化句柄与生命周期 + +后台 producer 返回类型化的规范句柄,例如 `{ kind: 'background', taskId }`,同时保留既有的 Native 语句。已预先中止的后台调用仍是失败,因为成功输出承诺返回 id,而此时并未创建任务。`ctx.tasks.start()` 发布 id 后,工作由任务自有的取消机制控制:外围 `run_code` 调用完成,或随后被取消,都不会终止该任务。后续程序可以把返回的 id 传给 `task_output`;取消则由 `task_kill`、owner dispose 或服务 teardown 负责。前台执行仍与本次调用的信号耦合。任务生命周期契约由[后台任务运行时 Agent Note](../architecture/2026-06-20-generic-long-running-tool-runtime.md)定义。 + +动态 Cordis 挂载遵循同一规则:`cordis_mount` 返回 `{ id, pluginName, state, provides, waitingFor }`,因此程序可以直接读取 `mounted.id`,检查 active 或 pending 状态,并把该 id 传给 `cordis_unmount`,无需解析稳定的 Native 语句。 + +### 持久化、元数据与输出落盘 + +嵌套分发会为诊断保留既有的有界 `tool/code-dispatch.resultSummary`,但不会持久化规范值。`tool/result` 继续只持久化渲染后的内容、错误和可选元数据。这并非会话格式变更,因此 `SESSION_FORMAT_VERSION` 保持不变,回放也无法重建程序的中间值。 + +不透明的 `exec.parent` token 用于标识嵌套调用。由于这些调用没有直接对应的结果卡片,而且其规范值永远不会进入上下文,展示元数据以及通用或工具自有的输出落盘投影都会跳过它们。只有外层 `run_code` 调用会生成一张卡片,并且可能将 post-policy 处理后的最终展示写入落盘文件;`run_code` 有意既不声明结果展示器,也不声明展示元数据,因此 ACP 和 TUI 通过其通用的原始内容回退机制,使用持久化的 `tool/result.content` 补全该卡片。 + +## 测试 + +编译期测试与快照测试锁定了精确的 `ToolArgsMap`、`ToolOutputMap`、`ToolName`、schema 到 TypeScript 的覆盖范围以及特殊名称。注册表与真实 worker 测试覆盖标量、数组、对象和 null 值;字符串原文渲染;缺席的 `undefined`;消费方声明并用于 reject 的真实异常类,包括 `ToolCallError`;无效参数与完成值,包括伪装为内建原型的伪造原型;模型代码修改过的 JSON 边界全局对象、原型方法、构造函数槽位,以及继承而来的属性描述符字段;上述修改后的类型化绑定失败;不设上限的大型中间绑定值;嵌套输出落盘抑制;64 MiB 上限内外的精确计量;日志、值与诊断的组合计量;抛出的超大堆栈;有界失败的输出落盘;不可信对端伪造的流量;以及构建后包的执行。 + +无密钥的真实 worker 集成测试锁定了自然语言结果无法安全支持的两种句柄工作流。后台 bash 调用返回 task id,外层运行结束,之后的运行再根据该 id 轮询直至任务完成;其他用例分别证明,预先中止不会创建任务、发布后的调用取消会保留任务、前台执行仍与信号耦合,并且取消归 `task_kill` 所有。Cordis 程序会直接读取 active 或 pending 挂载的 id 和 `waitingFor` 字段,按该 id 卸载,并在不解析渲染文本的情况下确认挂载已移除。 + +## 备选方案 + +**返回 Native 文本并附加可选 JSON:**不予采纳。程序会面对两套相互竞争的成功契约;可选值不存在时,仍需使用工具专属的解析规则。规范值才是 API;Native 内容只是它的展示。 + +**让每个绑定返回成功/失败联合:**不予采纳。失败没有稳定的程序化分类体系。reject 保留普通的 `try`/`catch` 控制流,并且只暴露工具名与可供人阅读的消息。 + +**限制每个中间绑定值:**不予采纳。中间值不会进入模型上下文,任意截断会破坏程序化组合。明确的边界仍是生产方的采集契约与进程内存。 + +**静默检查格式化或截断过大的完成值:**不予采纳。把 JSON 值改成字符串既有损又违反类型。显式的 `output-limit` 失败让模型可以选择返回更小的结果,而保留的日志和诊断仍可使用普通的外层输出落盘机制。 + +## 影响 + +Code Mode 程序可以通过稳定值组合工具,无需逆向解析 Native 自然语言。Native 和 Both Mode 保留现有文本与编辑器展示,Code Mode 则获得输出 schema 类型和精确的运行时 JSON。工具作者必须把规范值视为程序化 API,并将仅用于展示的格式化放入渲染器。 + +worker 会以嵌套深度有界的扁平协议格式传输数据并执行无损校验,但不会降低中间值的开销,也不会使其具备持久性。外层输出溢出会显式导致运行失败,错误处理则有意由人类引导,而不是依赖带版本的错误代码联合。 + +## 已知限制与延后工作 + +- 即使工具输出可以采用任意 JSON 根,subagent 和工作流中由调用方定义的结构化输出仍通过消费方级别的门禁保持对象根限制。 +- Post-execute 分别提供值投影与展示投影;替换内容不是保密机制,因此策略若需向程序化调用方隐藏内容,就必须阻止调用或替换值。 +- 中间规范值仅存在于执行期间,无法用于回放,因为持久事件只存储展示和有界摘要。 +- 中间值没有字节上限,可能因值的保留、扁平协议格式副本或结构化克隆开销而耗尽进程或 worker 内存。 +- 64 MiB 硬上限只适用于外层可变负载,不计固定的结果封装语法与展示空白;输出落盘无法恢复超出该上限后被拒绝的字节。 +- 提供方或执行器的采集上限可能在规范值到达 Code Mode 前就已丢弃部分源数据。 +- 不支持的 MCP 输出 schema 会回退为 `JsonValue`;更丰富的 Native 多媒体投影留待后续实现。 +- 每个外层 `run_code` 只有一张结果卡片,嵌套调用不会各自生成卡片。 +- Code Mode 失败只暴露 `ToolCallError` 的消息与工具名,不提供程序可用的错误代码联合。 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 da2b0e8c51..6e832fb73b 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 d2b9e68ae1..24ddfea11e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -297,20 +297,17 @@ export interface Config { * nobody will resolve). */ maxWallMs?: number - /** Shared byte budget for captured log text (console + raw stream writes), truncation marked in-band. */ - maxLogBytes?: number /** - * Byte cap for the completion value, measured by its real cross-boundary - * size (string bytes, or structured-clone wire size); an oversized or - * non-cloneable value crosses as a capped string rendering. + * Hard cap for serialized log-array, completion-value, and failure-message payloads; + * fixed result-envelope syntax is excluded. */ - maxValueBytes?: number + maxOutputBytes?: number /** The worker's max old-generation heap in MiB (`resourceLimits`); overflow kills the worker, surfacing as kind `'worker-exit'`. */ maxOldGenerationSizeMb?: number } ``` -Source: [`packages/code-runtime/code-runtime-worker/src/index.ts:21`](../packages/code-runtime/code-runtime-worker/src/index.ts) +Source: [`packages/code-runtime/code-runtime-worker/src/index.ts:24`](../packages/code-runtime/code-runtime-worker/src/index.ts) ## `@deepseek-ai/dsh-compact-basic` @@ -755,7 +752,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` @@ -1135,7 +1132,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:51`](../packages/spill/spill-policy/src/index.ts) ## `@deepseek-ai/dsh-subagent-acp` @@ -1275,7 +1272,7 @@ export interface Config { } ``` -Source: [`packages/bash/tool-bash/src/index.ts:40`](../packages/bash/tool-bash/src/index.ts) +Source: [`packages/bash/tool-bash/src/index.ts:41`](../packages/bash/tool-bash/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` @@ -1387,7 +1384,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` @@ -1457,7 +1454,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` @@ -1511,7 +1508,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` @@ -1533,7 +1530,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:419`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:517`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-tui` @@ -1590,7 +1587,7 @@ export interface TuiConfig { } ``` -Source: [`packages/ui/tui/src/index.ts:216`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:217`](../packages/ui/tui/src/index.ts) ## `@deepseek-ai/dsh-tui-demo` diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml index 8dc6c89aea..2c8f18513b 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: 9e8fa1287c854f33f62a4c6a1ed93adfccc19471 -adding-a-tool.zh.md: be4e0800036ac9bde949a11d8a35e49cfb92efd7 +adding-a-tool.md: 788c967cffa9df06e8d96e190930d69b9f2182ed +adding-a-tool.zh.md: 4ff9ce25a66625435ccba4454f2ac4b5472e5668 diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index 9e8fa1287c..788c967cff 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, { encoding: 'utf8', signal: exec.signal }) }] + return readFile(args.path, { encoding: 'utf8', signal: exec.signal }) }, })) } @@ -35,31 +39,34 @@ 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`, the required caller-owned `signal`, 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. Only an around-dispatch wrapper receives a mutable view, and it may replace and restore the required `exec.signal` to impose a deadline but cannot remove it. -- **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 -Gate `run_in_background` with producer config, then register through `ctx.tasks.start({ kind, label, owner: exec.agent, run })`. The registry skips a pre-aborted invocation before the producer body; the runtime validates ownership and control-surface availability before `run()` starts work, then supplies the id, session fence, generic control tools, notices, and owner cleanup. +Gate `run_in_background` with producer config, then register through `ctx.tasks.start({ kind, label, owner: exec.agent, run })`. The registry rejects a pre-aborted invocation before the producer body; the runtime validates ownership and control-surface availability before `run()` starts work, then supplies the id, session fence, generic control tools, notices, and owner cleanup. A successful background branch returns a typed canonical handle such as `{ kind: 'background', taskId }`; its Native renderer may keep human prose such as `started background task bash-1`, but Code Mode must never parse that prose to recover the id. -The producer supplies synchronous `cancel`, non-rejecting `done` that settles after resource cleanup, and optional consuming `readOutput` with bounded-output formatting. Once the id is returned, use a task-owned cancellation signal rather than `exec.signal`. See the [background task runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) and `dsh-tool-bash` for a stream producer. +The producer supplies synchronous `cancel`, non-rejecting `done` that settles after resource cleanup, and optional consuming `readOutput` with bounded-output formatting. A pre-aborted call is a failure because no task exists whose id could satisfy the successful output schema. Once `ctx.tasks.start()` publishes the id, use a task-owned cancellation signal rather than `exec.signal`: later outer-call cancellation stops waiting for the call but does not kill published work; `task_kill`, owner disposal, and service teardown own that lifetime. Foreground work remains coupled to `exec.signal`. See the [background task runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) and `dsh-tool-bash` for a stream producer. ## 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 -In [Code Mode](../../packages/core/tools/README.md), every visible registered tool is available as `await tools.(args)` without extra integration. The SDK derives parameters from the same JSON Schema, and calls re-enter the normal execution pipeline. Write descriptions as model-facing API docs; non-text result blocks become placeholders in programs. +In [Code Mode](../../packages/core/tools/README.md), every visible registered tool is available as `await tools.(args)` without extra integration. The generated `ToolArgsMap` and `ToolOutputMap` derive exact argument and canonical-return types from the same schemas, and calls re-enter the normal execution pipeline. A successful call resolves to the final canonical JSON value after policy, not to rendered Native content. A failed call rejects with the real `ToolCallError`; programs can inspect only its `name`, `toolName`, and human-readable `message`, not internal error codes or a failure union. + +Design `output.schema` as a useful programmatic API: return handles and fields directly, allow scalar/array/null roots when they are the honest value, and keep human explanation in `output.render`. Intermediate values are execution-local, are not persisted or prompt-truncated, and have no byte cap, so the producer's truthful acquisition bounds and process memory still matter. Only the outer `run_code` logs/result cross the configurable output cap and model-facing spill pipeline. ## 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 +77,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 be4e080003..4ff9ce25a6 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, { encoding: 'utf8', signal: exec.signal }) }] + return readFile(args.path, { encoding: 'utf8', signal: exec.signal }) }, })) } @@ -35,31 +39,34 @@ 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`、必填且由调用方持有的 `signal`,以及可选的外层传输 `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)。 ## 长时间运行的工作 -通过 producer 配置控制 `run_in_background`,然后使用 `ctx.tasks.start({ kind, label, owner: exec.agent, run })` 注册任务。注册表会在进入 producer 主体前跳过已预先中止的调用;运行时会在 `run()` 启动工作前校验 owner 和控制面是否可用,随后提供 id、会话围栏、通用控制工具、通知和 owner cleanup。 +通过 producer 配置控制 `run_in_background`,然后使用 `ctx.tasks.start({ kind, label, owner: exec.agent, run })` 注册任务。注册表会在进入 producer 主体前将已预先中止的调用判为失败;运行时会在 `run()` 启动工作前校验 owner 和控制面是否可用,随后提供 id、会话围栏、通用控制工具、通知和 owner cleanup。成功的后台分支会返回类型化的规范句柄,如 `{ kind: 'background', taskId }`;其 Native 渲染器可以保留 `started background task bash-1` 这类供人阅读的自然语言,但 Code Mode 绝不能通过解析该文本取得 id。 -producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 `done`,以及可选的消费式 `readOutput`(负责有界输出的格式化)。返回 id 后,应使用 task 自有的取消信号,而不是 `exec.signal`。流式 producer 的示例和完整契约见[后台 task 运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)与 `dsh-tool-bash`。 +producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 `done`,以及可选的消费式 `readOutput`(负责有界输出的格式化)。预先中止的调用属于失败,因为此时没有任务,其 id 无法满足成功输出 schema。`ctx.tasks.start()` 发布 id 后,应使用任务自有的取消信号,而不是 `exec.signal`:之后取消外层调用只会停止等待本次调用,不会终止已经发布的工作;该生命周期归 `task_kill`、owner dispose 和服务 teardown 所有。前台工作仍与 `exec.signal` 耦合。流式 producer 的示例和完整契约见[后台 task 运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)与 `dsh-tool-bash`。 ## 执行策略与观测 -尽量不要把部署策略内建到工具中。使用 `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 自动触达你的工具 -在 [Code Mode](../../packages/core/tools/README.md) 中,每个可见的已注册工具都可通过 `await tools.(args)` 调用,无需额外集成。SDK 从同一份 JSON Schema 派生参数,调用重新进入正常的执行流水线。请将描述写成面向模型的 API 文档;非文本结果块在程序中变为占位符。 +在 [Code Mode](../../packages/core/tools/README.md) 中,每个可见的已注册工具都可通过 `await tools.(args)` 调用,无需额外集成。生成的 `ToolArgsMap` 和 `ToolOutputMap` 会根据同一组 schema 分别派生精确的参数类型与规范返回类型,调用则重新进入正常的执行流水线。成功调用会解析为策略处理后的最终规范 JSON 值,而不是渲染后的 Native 内容。失败调用会以真正的 `ToolCallError` reject;程序只能检查其 `name`、`toolName` 和可供人阅读的 `message`,无法取得内部错误代码或失败联合。 + +请把 `output.schema` 设计为实用的程序化 API:直接返回句柄与字段;当标量、数组或 null 确实就是结果时,允许采用相应的根类型;将面向人类的解释放入 `output.render`。中间值只存在于执行期间,不会被持久化或按提示词上限截断,也不设字节上限,因此生产方如实声明的采集边界和进程内存仍然重要。只有外层 `run_code` 日志/结果会受到可配置输出上限和面向模型的输出落盘流水线约束。 ## 工具在编辑器中的渲染方式(ACP 展示) -工具的 `execute` 返回模型可见的内容;其**编辑器卡片**是一个独立的、可选的关注点,通过 `defineTool` 选项中的两个纯展示方法声明。请与 `execute` 同步设计,而非事后补充——编辑器(如 Zed,通过 ACP(Agent Client Protocol)桥接)会展示该卡片,没有展示方法的工具回退为一个朴素的通用卡片(标题 = 工具名,原始 args 作为输入)。 +工具的 `output.render` 返回模型可见的内容;其**编辑器卡片**是另一项独立关注点,通过纯展示投影以及可选的 `presentCall`/`presentResult` 方法声明。请将这些内容与规范值一并设计:编辑器(如 Zed,通过 ACP(Agent Client Protocol)桥接)会展示该卡片,没有 UI 展示方法的工具则回退到通用卡片(标题 = 工具名,原始 args 作为输入)。 两个方法都返回一个 **`card` 标签的渲染意图**——选择与你的工具行为匹配的卡片类型: @@ -70,16 +77,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 4ca3fd0601..6becfd9434 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -773,7 +773,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:123`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:143`](../../packages/core/tools/src/index.ts) ### `tools/execute` — waterfall @@ -795,7 +795,7 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor Types: [Scoped](../core-data-structures/scope.md) · [ToolDispatchExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:93`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:113`](../../packages/core/tools/src/index.ts) ### `tools/post-execute` — waterfall @@ -818,7 +818,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:105`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:125`](../../packages/core/tools/src/index.ts) ### `tools/pre-execute` — waterfall @@ -839,7 +839,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:82`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:102`](../../packages/core/tools/src/index.ts) ### `tools/result` — emit @@ -858,7 +858,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:113`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:133`](../../packages/core/tools/src/index.ts) ## `workflow/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 5ac93a39de..f58e699bc7 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -317,11 +317,11 @@ list(): BashEnvVariableInfo[] Types: [DshEnvironment](../core-data-structures/bash.md) · [ToolExecution](../core-data-structures/tools.md) -Source: [`packages/bash/tool-bash/src/index.ts:103`](../../packages/bash/tool-bash/src/index.ts) +Source: [`packages/bash/tool-bash/src/index.ts:104`](../../packages/bash/tool-bash/src/index.ts) ## `ctx.codeRuntime` — `CodeRuntime` (abstract seam) -Registers one `ctx.codeRuntime` implementation. Program, budget, abort, and substrate failures resolve in CodeRunResult; only seam misuse rejects. Implementations bridge structured-cloneable bindings while treating programs as hostile peers, isolate runs from one another, and terminate and await in-flight runs during disposal. +Registers one `ctx.codeRuntime` implementation. Program, budget, abort, and substrate failures resolve in CodeRunResult; only seam misuse rejects. Implementations bridge structured-cloneable bindings, materialize each declared namespace rejection class, treat programs as hostile peers, isolate runs from one another, and terminate and await in-flight runs during disposal. ```ts cordis-catalog /** @@ -338,7 +338,7 @@ abstract run(request: CodeRunRequest): Promise Types: [CodeRunRequest](../core-data-structures/code-runtime.md) · [CodeRunResult](../core-data-structures/code-runtime.md) -Source: [`packages/code-runtime/code-runtime/src/index.ts:30`](../../packages/code-runtime/code-runtime/src/index.ts) +Source: [`packages/code-runtime/code-runtime/src/index.ts:33`](../../packages/code-runtime/code-runtime/src/index.ts) ## `ctx.commands` — `CommandService` @@ -1612,7 +1612,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:524`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:622`](../../packages/core/tools/src/index.ts) ## `ctx.tui` — `TuiExtensionService` (abstract seam) @@ -1635,7 +1635,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession ``` -Source: [`packages/ui/tui/src/index.ts:131`](../../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:132`](../../packages/ui/tui/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/cordis-tutorial/07-into-the-harness.md b/docs/cordis-tutorial/07-into-the-harness.md index d25419afd5..a86c538d48 100644 --- a/docs/cordis-tutorial/07-into-the-harness.md +++ b/docs/cordis-tutorial/07-into-the-harness.md @@ -21,8 +21,12 @@ export function apply(ctx: Context) { parameters: { name: { type: 'string', required: true, description: 'Who to greet' }, }, + 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}!` }, })) @@ -40,7 +44,7 @@ export function apply(ctx: Context) { } ``` -Every pattern here is from the earlier chapters: `inject: ['tools']` ([chapter 3](03-services.md)) holds the plugin until the tool registry exists; `ctx.tools.register(...)` attaches the registration disposer to the plugin ([chapter 2](02-lifecycle-and-effects.md)), so unloading unregisters the tool. `defineTool` converts the `parameters` spec to the JSON Schema shown to the model, infers the type of `args`, and validates model-supplied arguments before `execute` runs. +Every pattern here is from the earlier chapters: `inject: ['tools']` ([chapter 3](03-services.md)) holds the plugin until the tool registry exists; `ctx.tools.register(...)` attaches the registration disposer to the plugin ([chapter 2](02-lifecycle-and-effects.md)), so unloading unregisters the tool. `defineTool` converts the `parameters` spec to the JSON Schema shown to the model, infers the type of `args`, and validates model-supplied arguments before `execute` runs. The tool returns the canonical value declared by `output.schema`; `output.render` separately produces the Native and durable result content. ## An observer plugin diff --git a/docs/core-data-structures/code-runtime.md b/docs/core-data-structures/code-runtime.md index af3fdbc649..41009f065e 100644 --- a/docs/core-data-structures/code-runtime.md +++ b/docs/core-data-structures/code-runtime.md @@ -1,6 +1,6 @@ # Code Runtime -The code-execution seam — a [capability seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) whose interface ([dsh-code-runtime](../../packages/code-runtime/code-runtime), `ctx.codeRuntime`) runs one model-written program against host-provided async bindings and reports what it printed and returned. Code execution is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). Backends differ by execution substrate and source language, both readonly descriptors on the service; the worker-thread backend and the tool-registry consumer (Code Mode) are specified in the [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md). +The code-execution seam — a [capability seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) whose interface ([dsh-code-runtime](../../packages/code-runtime/code-runtime), `ctx.codeRuntime`) runs one model-written program against host-provided async bindings and reports what it printed and returned. Code execution is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). Backends differ by execution substrate and source language, both readonly descriptors on the service; the worker-thread backend and tool-registry consumer are specified by the [Code Mode foundation](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) and [typed-return contract](../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md). Source: [`packages/code-runtime/code-runtime/src/types.ts`](../../packages/code-runtime/code-runtime/src/types.ts) @@ -45,12 +45,12 @@ The result reports an error as a **field**, never a rejection of `run()` — rep interface CodeRunResult { /** * The program's completion value (its top-level `return`), when it ran to - * completion and the value survived the runtime's serialization boundary; - * a non-transferable value is replaced by a string rendering, and a failed - * or value-less run leaves this absent. + * completion and the value crossed the runtime's lossless-JSON boundary. + * Invalid or over-limit completions fail the run instead of substituting a + * rendered string; a failed or value-less run leaves this absent. */ - value?: unknown - /** Text the program emitted, in order (capped by the implementation). */ + value?: CodeJsonValue + /** Text the program emitted, in order, bounded only as part of the outer result. */ logs: string[] /** Present iff the run failed; see {@link CodeRunFailure} for the taxonomy. */ error?: CodeRunFailure @@ -59,7 +59,23 @@ interface CodeRunResult { ## Bindings: host functions as program globals -Each `CodeBindingNamespace` becomes one global object of async callables inside the program (the Code Mode consumer passes one: `tools`). Arguments and resolutions must be structured-cloneable — a runtime may bridge calls across a serialization boundary — and a runtime treats binding names as hostile input (`__proto__` is an ordinary own property, never a prototype collision): +Each `CodeBindingNamespace` becomes one global object of async callables inside the program (the Code Mode consumer passes one: `tools`). Arguments and resolutions must be lossless JSON and cross without a seam-level byte cap; the runtime may bridge them through structured clone. A namespace may declare a program-visible error class without making the runtime know the consumer's names: the runtime injects the real constructor and turns rejected calls into its instances. A runtime also treats binding names as hostile input (`__proto__` is an ordinary own property, never a prototype collision): + +```ts type-equiv +/** + * Program-visible typed rejection for one binding namespace. The runtime + * injects a real error constructor under `name`; rejected member calls become + * its instances and expose the exact member name through + * `memberNameProperty`. Both strings are runtime data rather than knowledge + * of a particular consumer such as Code Mode. + */ +interface CodeBindingErrorClass { + /** Constructor global and resulting `Error.name` (must be a usable JS identifier). */ + name: string + /** Non-empty own property for the member name; cannot replace `name`, `message`, or `stack`. */ + memberNameProperty: string +} +``` ```ts type-equiv /** @@ -74,24 +90,32 @@ interface CodeBindingNamespace { global: string /** The callable members, keyed by the exact name the program calls. */ functions: Record + /** Optional program-visible typed rejection contract for this namespace. */ + errorClass?: CodeBindingErrorClass } ``` +```ts type-equiv +/** A lossless JSON value transferable across the dependency-light code-runtime seam. */ +type CodeJsonValue = null | boolean | number | string | CodeJsonValue[] | { [key: string]: CodeJsonValue } +``` + ```ts type-equiv /** * One host-side function exposed to the program as an async callable. The * runtime bridges calls to it (possibly across a serialization boundary), so - * `args` and the resolution value MUST be structured-cloneable; a runtime - * rejects a non-cloneable value with a descriptive error rather than - * corrupting the run. A rejection of this function surfaces inside the - * program as a rejection of the corresponding call. + * `args` and the resolution value MUST be lossless JSON. A runtime rejects a + * lossy or non-cloneable value with a descriptive error rather than corrupting + * the run. No seam-level byte cap applies to a binding resolution. A rejection + * of this function surfaces inside the program as a rejection of the + * corresponding call. */ -type CodeBindingFunction = (args: unknown) => Promise +type CodeBindingFunction = (args: unknown) => Promise ``` ## Captured output and the failure taxonomy -Logs are plain strings in emission order. The runtime captures the program's console and stream output, but channel and console-method metadata are not part of the seam because consumers render only the text. Implementations cap the aggregate output and mark truncation in-band. +Logs are plain strings in emission order. The runtime captures the program's console and stream output, but channel and console-method metadata are not part of the seam because consumers render only the text. Implementations cap the serialized outer log-array plus completion-value or failure-message payload; fixed result-envelope syntax and consumer presentation whitespace are not part of that variable-payload ledger. Overflow is an explicit failure rather than in-band value substitution. Failure kinds are **orthogonal outcomes reported independently** (per [defensive-patterns](../defensive-patterns.md)): a budget expiry is not an exception, an abort is not a timeout, and a substrate death (e.g. OOM) is neither: @@ -105,10 +129,12 @@ Failure kinds are **orthogonal outcomes reported independently** (per [defensive * - `'timeout'` — an implementation-owned budget expired; the message says which. * - `'abort'` — {@link CodeRunRequest.signal} fired. * - `'worker-exit'` — the execution substrate died without settling (e.g. OOM). + * - `'invalid-output'` — the completion value was not lossless JSON. + * - `'output-limit'` — the serialized outer logs/value/diagnostic exceeded the configured cap. */ interface CodeRunFailure { /** The failure class (see the interface doc for each kind's meaning). */ - kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' + kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' | 'invalid-output' | 'output-limit' /** Human-readable detail, suitable for feeding back to a model to self-correct. */ message: string } diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 6b5596b782..7f5c88b275 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 | |---|---| @@ -546,4 +546,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/filesystem.md b/docs/core-data-structures/filesystem.md index 84d1ce9c5a..25df997bb0 100644 --- a/docs/core-data-structures/filesystem.md +++ b/docs/core-data-structures/filesystem.md @@ -205,7 +205,7 @@ interface FsPolicyExec { ## Read outcome (consumer / read rendering) -A text read is bounded by line window, byte cap, and backend limits. The outcome the model-facing `read` tool renders is purely presentational; there is no `full`/`partial` view — authorization is freshness-based (the tool emits `fs/observed` with the stat's version directly), so any windowed read can authorize a later write/edit when the file is unchanged. Read windowing and this outcome shape live in `dsh-tool-fs` (the executor that owns the read), not in the policy plugin. +A text read is bounded by line window, byte cap, and backend limits. After the byte cap is reached, scanning continues without retaining more lines so `totalLines` remains exact. The outcome the model-facing `read` tool renders is purely presentational; there is no `full`/`partial` view — authorization is freshness-based (the tool emits `fs/observed` with the stat's version directly), so any windowed read can authorize a later write/edit when the file is unchanged. Read windowing and this outcome shape live in `dsh-tool-fs` (the executor that owns the read), not in the policy plugin. ```ts type-equiv /** Outcome of a bounded text read — what {@link formatReadOutput} renders. */ @@ -214,9 +214,9 @@ interface FileReadOutcome { offset: number /** Returned lines, already numbered. */ lines: FileTextLine[] - /** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */ + /** Exact total line count in the file. */ totalLines: number - /** Whether selected output hit the byte cap before EOF or the requested limit. */ + /** Whether selected output hit the byte cap. */ truncatedByBytes?: true } ``` diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 7f61be2e73..ba1dcb98ba 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -85,15 +85,25 @@ interface SessionEventMap { */ 'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string } /** - * A completed tool call's model-facing result, plus an optional tool-private - * `meta` presentation payload. `meta` is opaque to the core (`unknown` — the - * producing tool owns its shape and reads it back in `presentResult`) but MUST - * be JSON-serializable: `Session.append` runtime-validates all event data with - * `isJsonValue`, so a non-serializable `meta` is rejected at the source, and the - * durable log reproduces the identical card on replay. Absent unless the tool - * attaches one (e.g. `dsh-tool-fs` carries its result-time contextual diff here). + * A completed tool call's model-facing result, optional internal failure + * identity, and optional tool-private `meta` presentation payload. `meta` is + * opaque to the core (the producing tool owns its shape and reads it back in + * `presentResult`) but MUST be JSON-serializable: `Session.append` + * runtime-validates all event data with `isJsonValue`, so a non-serializable + * `meta` is rejected at the source, and the durable log reproduces the + * identical card on replay. Absent + * unless the tool attaches one (e.g. `dsh-tool-fs` carries its result-time + * contextual diff here). */ - 'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown } + 'tool/result': { + turn: number + step: number + callId: CallId + content: ContentBlock[] + isError: boolean + error?: { name: string; code: string } + meta?: JsonValue + } /** Steering content injected between steps of a running turn. */ 'steering/message': PromptMessageData & { turn: number } /** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */ diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 1fe311fd52..4f9cbdae61 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -66,11 +66,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 a4f523fe61..008361cd49 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -6,21 +6,36 @@ 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 { + /** Mandatory canonical output declaration. */ + readonly output: ToolOutputDefinition /** - * Run one accepted call. Async work must observe or forward `exec.signal` and - * settle only after its owned work reaches quiescence. The registry preserves - * caller cancellation through around-dispatch signal replacement and does - * not abandon this promise, but it cannot hard-kill same-process code. + * Run one accepted call and return only its canonical lossless-JSON value. + * Async work must observe or forward `exec.signal` and settle only after its + * owned work reaches quiescence. The registry preserves caller cancellation + * through around-dispatch signal replacement and does not abandon this + * promise, but it cannot hard-kill same-process code. * @param args - losslessly snapshotted, frozen model arguments. * @param exec - execution identity, cancellation signal, and context deferral. - * @returns model-facing content plus optional private presentation metadata. + * @returns the canonical value declared by `output.schema`. */ - execute(args: unknown, exec: ToolRunContext): Promise + 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 @@ -55,7 +70,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. @@ -64,69 +79,62 @@ 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 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 +/** + * Tool parameter schema. The map itself is an implicit open object root; + * requiredness remains a per-property `required: true` annotation. + */ +type ParameterSchemaSpec = { + [key: string]: ParameterPropertySpec + [key: symbol]: never } ``` -```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. - */ -type SchemaSpec = 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 through 16 container levels, then falls back to `JsonValue` instead of exhausting TypeScript's type-instantiation stack. `InferArgs

` turns per-property requiredness into required and optional string 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. Exact + * inference is bounded to 16 container levels, then falls back to `JsonValue`. */ -type InferArgs = Simplify< - & { [K in RequiredKeys]: InferPropValue } - & { [K in Exclude>]?: InferPropValue } -> +type InferValue = InferValueAt ``` -`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 +``` -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. +`defineTool({ name, description, parameters, output, execute, … })` ties parameter inference to `parameterSchemaSpecToJsonSchema()` and `validateArgs()`, and ties `execute`/`render`/`presentationMeta` to `InferValue`. Schema records contain only own enumerable string keys, and schema arrays are dense intrinsic arrays, so inference, compilation, and validation observe the same declaration. Inference stays exact through 16 container levels and then widens to `JsonValue`; 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, 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 @@ -252,34 +260,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`: @@ -298,67 +320,70 @@ 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 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 a04e633377..f62102bbe4 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), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:123`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:93`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | -| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:105`](../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:82`](../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:113`](../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:143`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | +| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:125`](../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:102`](../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:133`](../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`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 24fc34b890..289030e426 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -229,8 +229,6 @@ flowchart TD pkg_host_webserver --> pkg_invariants pkg_llm --> pkg_brand pkg_llm --> pkg_invariants - pkg_code_runtime_worker --> pkg_code_runtime - pkg_code_runtime_worker --> pkg_invariants pkg_helper --> pkg_brand pkg_helper --> pkg_invariants pkg_telemetry --> pkg_brand @@ -302,6 +300,9 @@ flowchart TD pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths pkg_app_boot --> pkg_system_prompt + pkg_code_runtime_worker --> pkg_code_runtime + pkg_code_runtime_worker --> pkg_invariants + pkg_code_runtime_worker --> pkg_session pkg_lsp_local --> pkg_brand pkg_lsp_local --> pkg_invariants pkg_lsp_local --> pkg_llm @@ -776,7 +777,6 @@ flowchart TD | [`host-runtime`](../packages/host/runtime) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | -| [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | @@ -801,6 +801,7 @@ flowchart TD | [`session-title`](../packages/session-title/session-title) | `session-title` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | +| [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`timeout`](../packages/util/timeout) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index b5a3c60463..10adedaafa 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:307`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:320`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:350`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:382`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:317`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:330`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:360`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:392`](../packages/core/session/src/types.ts) ## Events @@ -371,7 +371,7 @@ Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:295`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:305`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -427,7 +427,7 @@ Source: [`packages/session-title/session-title-llm/src/index.ts:44`](../packages 'steering/message': PromptMessageData & { turn: number } ``` -Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts) ### `step/*` @@ -460,7 +460,7 @@ Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/ Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:290`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:300`](../packages/core/session/src/types.ts) ### `tool/*` @@ -508,20 +508,30 @@ 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, optional internal failure + * identity, and optional tool-private `meta` presentation payload. `meta` is + * opaque to the core (the producing tool owns its shape and reads it back in + * `presentResult`) but MUST be JSON-serializable: `Session.append` + * runtime-validates all event data with `isJsonValue`, so a non-serializable + * `meta` is rejected at the source, and the durable log reproduces the + * identical card on replay. Absent + * unless the tool attaches one (e.g. `dsh-tool-fs` carries its result-time + * contextual diff here). */ -'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown } +'tool/result': { + turn: number + step: number + callId: CallId + content: ContentBlock[] + isError: boolean + error?: { name: string; code: string } + meta?: JsonValue +} ``` Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:286`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts) ### `turn/*` diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 5c1bfbfdf3..4f9de3644f 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -48,6 +48,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", @@ -66,6 +67,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", @@ -229,7 +231,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 } }, 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 { @@ -896,6 +898,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", @@ -937,7 +940,7 @@ Run a JavaScript workflow script that orchestrates subagents at scale. Use this 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. +- `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/oneOf — no 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. @@ -957,6 +960,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", @@ -975,6 +979,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", @@ -1006,7 +1011,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/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 d2f4343cf1..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: 416733bcb584fa5303a8b3ba5e6e904302e7f992 -tool.zh.md: fce9a7d9b973853c8b4fb9ae2c034e749d8da999 +tool.md: 7b211cfef54306f7c316dc08da1df759dcbf1b06 +tool.zh.md: 214b35b28de0c647737bc8297b13b4997947b52e diff --git a/docs/user/develop/basic/tool.md b/docs/user/develop/basic/tool.md index 416733bcb5..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}!` }, })) } @@ -37,10 +41,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 +54,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 +63,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 +89,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 @@ -101,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. @@ -142,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', @@ -190,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 fce9a7d9b9..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}!` }, })) } @@ -37,10 +41,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 +54,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 +63,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 +89,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 函数 @@ -101,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` 结果返回给模型,让模型自行修正。 @@ -142,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', @@ -190,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/session.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl index 3b7a3a06f2..e754dd5639 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -22,7 +22,7 @@ {"type":"assistant/message","seq":20,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} {"type":"tool/call","seq":21,"time":1783957884490,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}} {"type":"tool/code-dispatch","seq":22,"time":1783957884560,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"},"isError":false,"resultSummary":"## dynamic\n- dyn-1: snapshot-marker [active]"}} -{"type":"tool/result","seq":23,"time":1783957884561,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[21],"surfaceOp":"append"} +{"type":"tool/result","seq":23,"time":1783957884561,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"} {"type":"step/end","seq":24,"time":1783957884561,"data":{"turn":1,"step":2}} {"type":"step/start","seq":25,"time":1783957884562,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":26,"time":1783950000026,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} 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 2b46661722..73c31413bf 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 @@ -28,19 +28,21 @@ Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop o Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program: -- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable. -- A FAILED tool call rejects with an `Error` carrying the tool's error text — `try/catch` it to handle and continue. +- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. +- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue. - Calls execute sequentially, even under `Promise.all`. - Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need. The available tools: ```ts -declare const tools: { +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } + +interface ToolArgsMap { /** Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. Send one or more questions, each with a stable id that will be echoed in the answer. */ - ask_user_question(args: { + ask_user_question: { /** Questions to ask the user before continuing. */ - questions: { + questions: ({ /** Stable id for this question; echoed in the answer. */ id: string; /** The specific question to ask the user. */ @@ -48,18 +50,18 @@ declare const tools: { /** Optional short heading for the question, such as "Confirm" or "Choose Mode". */ header?: string; /** Optional choices to show the user. If you recommend one, put it first and append "(Recommended)" to that label. */ - options?: { + options?: ({ /** Short user-facing option label. */ label: string; /** One sentence explaining the tradeoff or impact. */ description?: string; - }[]; + } & Record)[]; /** Whether the user may select more than one option. Defaults to false. */ multi_select?: boolean; - }[]; - }): Promise; + } & Record)[]; + } & Record; /** 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: { + bash: { /** The bash command to execute. */ command: string; /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: "ls" → "List files in current directory"; "git status" → "Show working tree status"; "npm install" → "Install package dependencies". */ @@ -74,33 +76,33 @@ 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; /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:"api"` or `what:"events"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */ - cordis_inspect(args: { + cordis_inspect: { /** 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. */ - cordis_mount(args: { + } & Record; + /** 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: { /** Body of an async JS function; must `return` the plugin to mount. */ code: string; - }): Promise; + } & Record; /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */ - cordis_unmount(args: { + cordis_unmount: { /** The dynamic mount id returned by cordis_mount (e.g. "dyn-1"). */ id: string; - }): Promise; + } & Record; /** Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. */ - create_goal(args: { + create_goal: { /** 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; /** Edit an existing UTF-8 text file by replacing literal text. */ - edit(args: { + edit: { /** Path to edit, resolved by the filesystem backend. */ file_path: string; /** Literal text to replace. Must match exactly. */ @@ -113,83 +115,83 @@ 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; /** Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. Send the COMPLETE plan as markdown, starting with a # heading that names it. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again. */ - exit_plan_mode(args: { + exit_plan_mode: { /** The complete plan, as markdown, starting with a # heading that names it. */ plan: string; - }): Promise; + } & Record; /** 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: Record; /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ - ralph(args: { + ralph: { /** 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; /** Read a UTF-8 text file and return line-numbered content. */ - read(args: { + read: { /** Path to read, resolved by the filesystem backend. */ file_path: string; /** 1-based first line to return. Defaults to 1. */ offset?: number; /** Maximum number of lines to return. Defaults to 2000. */ limit?: number; - }): Promise; + } & Record; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ - skill(args: { + skill: { /** The exact skill name from the available skills list. */ name: string; - }): Promise; + } & Record; /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ - subagent(args: { + subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ 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; /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ - subagent_fork(args: { + subagent_fork: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */ 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; /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */ - task_kill(args: { + task_kill: { /** 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; /** List your background tasks (running and finished) with their ids, kinds, and statuses. */ - task_list(args: Record): Promise; + task_list: Record; /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */ - task_output(args: { + task_output: { /** Task id returned by the tool that started the background work. */ task_id: string; /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */ 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; /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */ - todo_write(args: { + todo_write: { /** The COMPLETE task list, replacing any previous list. */ todos: ({ /** What the task is — a short imperative line. */ content: string; /** pending (not started) | in_progress (now) | completed (done). */ status: "pending" | "in_progress" | "completed"; - })[]; - }): Promise; + } & Record)[]; + } & Record; /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ - update_goal(args: { + update_goal: { /** Exact id returned by get_goal. */ goal_id: string; /** Exact positive revision returned by get_goal. */ @@ -202,9 +204,9 @@ declare const tools: { max_goal_rounds?: number; /** Concrete blocking condition; required only with action blocked. */ blocked_reason?: string; - }): 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: { + } & Record; + /** 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/oneOf — no 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: { /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */ script: string; /** The workflow identity block (plain JSON — never code). */ @@ -216,7 +218,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. */ @@ -225,13 +227,13 @@ 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; /** Create or fully replace a UTF-8 text file. */ - write(args: { + write: { /** Path to write, resolved by the filesystem backend. */ file_path: string; /** Full UTF-8 text content to write. */ @@ -240,6 +242,225 @@ 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; +} + +interface ToolOutputMap { + ask_user_question: { + answers: { + id: string; + selected: string[]; + custom?: string; + }[]; + }; + bash: { + kind: "background"; + taskId: string; + } | { + kind: "foreground"; + exitCode: number | null; + signal: string | null; + timedOut: boolean; + aborted: boolean; + timeoutMs: number; + stdout: { + text: string; + truncated: boolean; + spillPath?: string; + }; + stderr: { + text: string; + truncated: boolean; + spillPath?: string; + }; + sandbox?: { + mode: string; + denied: boolean; + enforcement?: string; + runnerFailed?: boolean; + }; + }; + cordis_inspect: string; + cordis_mount: { + id: string; + pluginName: string; + state: "pending" | "loading" | "active" | "failed" | "disposed" | "unloading"; + provides: string[]; + waitingFor: string[]; + }; + cordis_unmount: { + id: string; + pluginName: string; + }; + create_goal: { + goal: null; + } | { + goal: { + id: string; + revision: number; + objective: string; + phase: "active" | "paused" | "blocked" | "complete"; + roundsStarted: number; + maxGoalRounds: number; + blockedReason?: { + code: string; + message: string; + }; + }; + activation: "armed" | "disarmed"; + }; + edit: { + path: string; + before: string; + after: string; + }; + exit_plan_mode: { + approved: true; + }; + get_goal: { + goal: null; + } | { + goal: { + id: string; + revision: number; + objective: string; + phase: "active" | "paused" | "blocked" | "complete"; + roundsStarted: number; + maxGoalRounds: number; + blockedReason?: { + code: string; + message: string; + }; + }; + activation: "armed" | "disarmed"; + }; + ralph: { + runId: string; + agentsStarted: number; + result: JsonValue; + }; + read: { + path: string; + offset: number; + lines: { + number: number; + text: string; + }[]; + totalLines: number; + }; + skill: { + name: string; + provider: string; + resourceBase?: { + kind: "directory"; + path: string; + } | { + kind: "url"; + url: string; + } | { + kind: "opaque"; + description: string; + }; + content: string; + }; + subagent: { + kind: "background"; + taskId: string; + } | { + kind: "foreground"; + runId: string; + output: JsonValue[]; + }; + subagent_fork: { + kind: "background"; + taskId: string; + } | { + kind: "foreground"; + runId: string; + output: JsonValue[]; + }; + task_kill: { + outcome: "cancellation-requested" | "already-finished"; + task: { + id: string; + kind: string; + label: string; + status: "running" | "stopping" | "completed" | "killed" | "failed"; + detail?: string; + startedAt: number; + finishedAt?: number; + }; + }; + task_list: ({ + id: string; + kind: string; + label: string; + status: "running" | "stopping" | "completed" | "killed" | "failed"; + detail?: string; + startedAt: number; + finishedAt?: number; + })[]; + task_output: { + text: string; + task: { + id: string; + kind: string; + label: string; + status: "running" | "stopping" | "completed" | "killed" | "failed"; + detail?: string; + startedAt: number; + finishedAt?: number; + }; + }; + todo_write: { + todos: ({ + content: string; + status: "pending" | "in_progress" | "completed"; + })[]; + counts: { + pending: number; + inProgress: number; + completed: number; + }; + }; + update_goal: { + goal: null; + } | { + goal: { + id: string; + revision: number; + objective: string; + phase: "active" | "paused" | "blocked" | "complete"; + roundsStarted: number; + maxGoalRounds: number; + blockedReason?: { + code: string; + message: string; + }; + }; + activation: "armed" | "disarmed"; + }; + workflow: { + runId: string; + agentsStarted: number; + result: JsonValue; + }; + write: { + path: string; + operation: "create" | "update"; + before: string | null; + after: string; + }; +} + +type ToolName = keyof ToolOutputMap + +declare class ToolCallError extends Error { + readonly name: "ToolCallError"; + readonly toolName: ToolName; +} + +declare const tools: { + [K in ToolName]: (args: ToolArgsMap[K]) => 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 4b3868a31d..6b50a5d220 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 @@ -11,6 +11,7 @@ "description": "Questions to ask the user before continuing.", "items": { "type": "object", + "additionalProperties": true, "properties": { "id": { "type": "string", @@ -29,6 +30,7 @@ "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", @@ -133,7 +135,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 } }, 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": { @@ -438,6 +440,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -512,7 +515,7 @@ }, { "name": "workflow", - "description": "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.\n\nThe 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.\n\nScript-body hooks:\n- `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.\n- `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.\n- `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`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: 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.", + "description": "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.\n\nThe 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.\n\nScript-body hooks:\n- `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/oneOf — no 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.\n- `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.\n- `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`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: 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.", "parameters": { "type": "object", "properties": { @@ -523,6 +526,7 @@ "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -541,6 +545,7 @@ "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -572,7 +577,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/session.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl index 358ea0c331..cbc7e2cd3a 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl @@ -77,18 +77,18 @@ {"type":"assistant/chunk","seq":75,"time":1783611775407,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_AZFzvUwuC4vAUoICrfke5147","name":"run_code","argumentsDelta":"\\\""}}} {"type":"assistant/chunk","seq":76,"time":1783611775408,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_AZFzvUwuC4vAUoICrfke5147","name":"run_code","argumentsDelta":" });\\n"}}} {"type":"assistant/chunk","seq":77,"time":1783611775408,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_AZFzvUwuC4vAUoICrfke5147","name":"run_code","argumentsDelta":"return"}}} -{"type":"assistant/chunk","seq":78,"time":1783611775436,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_AZFzvUwuC4vAUoICrfke5147","name":"run_code","argumentsDelta":" result"}}} +{"type":"assistant/chunk","seq":78,"time":1783611775436,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_AZFzvUwuC4vAUoICrfke5147","name":"run_code","argumentsDelta":" result.stdout.text"}}} {"type":"assistant/chunk","seq":79,"time":1783611775437,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_AZFzvUwuC4vAUoICrfke5147","name":"run_code","argumentsDelta":";"}}} {"type":"assistant/chunk","seq":80,"time":1783611775437,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_AZFzvUwuC4vAUoICrfke5147","name":"run_code","argumentsDelta":"\""}}} {"type":"assistant/chunk","seq":81,"time":1783611775474,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_AZFzvUwuC4vAUoICrfke5147","name":"run_code","argumentsDelta":"}"}}} {"type":"assistant/chunk","seq":82,"time":1783611775497,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the `run_code` tool to execute a program that calls `tools.bash` with the command `echo BOTH_OK` and returns its output."}}}} -{"type":"assistant/chunk","seq":83,"time":1783611775498,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_AZFzvUwuC4vAUoICrfke5147","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Echo BOTH_OK\\\" });\\nreturn result;\"}"}}}} +{"type":"assistant/chunk","seq":83,"time":1783611775498,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_AZFzvUwuC4vAUoICrfke5147","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Echo BOTH_OK\\\" });\\nreturn result.stdout.text;\"}"}}}} {"type":"assistant/chunk","seq":84,"time":1783611775498,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":5530,"outputTokens":108,"cacheReadTokens":0,"reasoningTokens":37}}}} {"type":"assistant/chunk","seq":85,"time":1783611775498,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":86,"time":1783611775503,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the `run_code` tool to execute a program that calls `tools.bash` with the command `echo BOTH_OK` and returns its output."},{"type":"tool-call","id":"call_00_AZFzvUwuC4vAUoICrfke5147","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Echo BOTH_OK\\\" });\\nreturn result;\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":5530,"outputTokens":108,"cacheReadTokens":0,"reasoningTokens":37}},"sourceEventSeqs":[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,76,77,78,79,80,81,82,83,84,85],"surfaceOp":"append"} -{"type":"tool/call","seq":87,"time":1783611775504,"data":{"turn":1,"step":1,"callId":"call_00_AZFzvUwuC4vAUoICrfke5147","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Echo BOTH_OK\\\" });\\nreturn result;\"}"}} +{"type":"assistant/message","seq":86,"time":1783611775503,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the `run_code` tool to execute a program that calls `tools.bash` with the command `echo BOTH_OK` and returns its output."},{"type":"tool-call","id":"call_00_AZFzvUwuC4vAUoICrfke5147","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Echo BOTH_OK\\\" });\\nreturn result.stdout.text;\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":5530,"outputTokens":108,"cacheReadTokens":0,"reasoningTokens":37}},"sourceEventSeqs":[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,76,77,78,79,80,81,82,83,84,85],"surfaceOp":"append"} +{"type":"tool/call","seq":87,"time":1783611775504,"data":{"turn":1,"step":1,"callId":"call_00_AZFzvUwuC4vAUoICrfke5147","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Echo BOTH_OK\\\" });\\nreturn result.stdout.text;\"}"}} {"type":"tool/code-dispatch","seq":88,"time":1783611775590,"data":{"parentCallId":"call_00_AZFzvUwuC4vAUoICrfke5147","subCallId":"call_00_AZFzvUwuC4vAUoICrfke5147:code:1","name":"bash","arguments":{"command":"echo BOTH_OK","description":"Echo BOTH_OK"},"isError":false,"resultSummary":"BOTH_OK\n"}} -{"type":"tool/result","seq":89,"time":1783611775592,"data":{"turn":1,"step":1,"callId":"call_00_AZFzvUwuC4vAUoICrfke5147","content":[{"type":"text","text":"BOTH_OK\n"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[87],"surfaceOp":"append"} +{"type":"tool/result","seq":89,"time":1783611775592,"data":{"turn":1,"step":1,"callId":"call_00_AZFzvUwuC4vAUoICrfke5147","content":[{"type":"text","text":"BOTH_OK\n"}],"isError":false},"sourceEventSeqs":[87],"surfaceOp":"append"} {"type":"step/end","seq":90,"time":1783611775592,"data":{"turn":1,"step":1}} {"type":"step/start","seq":91,"time":1783611775592,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":92,"time":1783611776183,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl index 99d2f0bd5b..4909c470b2 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl @@ -39,7 +39,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" its"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_AZFzvUwuC4vAUoICrfke5147","title":"const result = await tools.bash({ command: \"echo BOTH_OK\", description: \"Echo BOTH_OK\" });\nreturn result;","kind":"execute","status":"in_progress","rawInput":"const result = await tools.bash({ command: \"echo BOTH_OK\", description: \"Echo BOTH_OK\" });\nreturn result;"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_AZFzvUwuC4vAUoICrfke5147","title":"const result = await tools.bash({ command: \"echo BOTH_OK\", description: \"Echo BOTH_OK\" });\nreturn result.stdout.text;","kind":"execute","status":"in_progress","rawInput":"const result = await tools.bash({ command: \"echo BOTH_OK\", description: \"Echo BOTH_OK\" });\nreturn result.stdout.text;"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_AZFzvUwuC4vAUoICrfke5147","status":"completed","content":[{"type":"content","content":{"type":"text","text":"BOTH_OK\n"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} 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 8868412707..8744029272 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 @@ -28,19 +28,21 @@ Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop o Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program: -- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable. -- A FAILED tool call rejects with an `Error` carrying the tool's error text — `try/catch` it to handle and continue. +- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. +- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue. - Calls execute sequentially, even under `Promise.all`. - Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need. The available tools: ```ts -declare const tools: { +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } + +interface ToolArgsMap { /** Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. Send one or more questions, each with a stable id that will be echoed in the answer. */ - ask_user_question(args: { + ask_user_question: { /** Questions to ask the user before continuing. */ - questions: { + questions: ({ /** Stable id for this question; echoed in the answer. */ id: string; /** The specific question to ask the user. */ @@ -48,18 +50,18 @@ declare const tools: { /** Optional short heading for the question, such as "Confirm" or "Choose Mode". */ header?: string; /** Optional choices to show the user. If you recommend one, put it first and append "(Recommended)" to that label. */ - options?: { + options?: ({ /** Short user-facing option label. */ label: string; /** One sentence explaining the tradeoff or impact. */ description?: string; - }[]; + } & Record)[]; /** Whether the user may select more than one option. Defaults to false. */ multi_select?: boolean; - }[]; - }): Promise; + } & Record)[]; + } & Record; /** 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: { + bash: { /** The bash command to execute. */ command: string; /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: "ls" → "List files in current directory"; "git status" → "Show working tree status"; "npm install" → "Install package dependencies". */ @@ -74,16 +76,16 @@ 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; /** Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. */ - create_goal(args: { + create_goal: { /** 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; /** Edit an existing UTF-8 text file by replacing literal text. */ - edit(args: { + edit: { /** Path to edit, resolved by the filesystem backend. */ file_path: string; /** Literal text to replace. Must match exactly. */ @@ -96,83 +98,83 @@ 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; /** Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. Send the COMPLETE plan as markdown, starting with a # heading that names it. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again. */ - exit_plan_mode(args: { + exit_plan_mode: { /** The complete plan, as markdown, starting with a # heading that names it. */ plan: string; - }): Promise; + } & Record; /** 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: Record; /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ - ralph(args: { + ralph: { /** 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; /** Read a UTF-8 text file and return line-numbered content. */ - read(args: { + read: { /** Path to read, resolved by the filesystem backend. */ file_path: string; /** 1-based first line to return. Defaults to 1. */ offset?: number; /** Maximum number of lines to return. Defaults to 2000. */ limit?: number; - }): Promise; + } & Record; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ - skill(args: { + skill: { /** The exact skill name from the available skills list. */ name: string; - }): Promise; + } & Record; /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ - subagent(args: { + subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ 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; /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ - subagent_fork(args: { + subagent_fork: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */ 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; /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */ - task_kill(args: { + task_kill: { /** 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; /** List your background tasks (running and finished) with their ids, kinds, and statuses. */ - task_list(args: Record): Promise; + task_list: Record; /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */ - task_output(args: { + task_output: { /** Task id returned by the tool that started the background work. */ task_id: string; /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */ 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; /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */ - todo_write(args: { + todo_write: { /** The COMPLETE task list, replacing any previous list. */ todos: ({ /** What the task is — a short imperative line. */ content: string; /** pending (not started) | in_progress (now) | completed (done). */ status: "pending" | "in_progress" | "completed"; - })[]; - }): Promise; + } & Record)[]; + } & Record; /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ - update_goal(args: { + update_goal: { /** Exact id returned by get_goal. */ goal_id: string; /** Exact positive revision returned by get_goal. */ @@ -185,9 +187,9 @@ declare const tools: { max_goal_rounds?: number; /** Concrete blocking condition; required only with action blocked. */ blocked_reason?: string; - }): 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: { + } & Record; + /** 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/oneOf — no 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: { /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */ script: string; /** The workflow identity block (plain JSON — never code). */ @@ -199,7 +201,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. */ @@ -208,13 +210,13 @@ 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; /** Create or fully replace a UTF-8 text file. */ - write(args: { + write: { /** Path to write, resolved by the filesystem backend. */ file_path: string; /** Full UTF-8 text content to write. */ @@ -223,6 +225,213 @@ 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; +} + +interface ToolOutputMap { + ask_user_question: { + answers: { + id: string; + selected: string[]; + custom?: string; + }[]; + }; + bash: { + kind: "background"; + taskId: string; + } | { + kind: "foreground"; + exitCode: number | null; + signal: string | null; + timedOut: boolean; + aborted: boolean; + timeoutMs: number; + stdout: { + text: string; + truncated: boolean; + spillPath?: string; + }; + stderr: { + text: string; + truncated: boolean; + spillPath?: string; + }; + sandbox?: { + mode: string; + denied: boolean; + enforcement?: string; + runnerFailed?: boolean; + }; + }; + create_goal: { + goal: null; + } | { + goal: { + id: string; + revision: number; + objective: string; + phase: "active" | "paused" | "blocked" | "complete"; + roundsStarted: number; + maxGoalRounds: number; + blockedReason?: { + code: string; + message: string; + }; + }; + activation: "armed" | "disarmed"; + }; + edit: { + path: string; + before: string; + after: string; + }; + exit_plan_mode: { + approved: true; + }; + get_goal: { + goal: null; + } | { + goal: { + id: string; + revision: number; + objective: string; + phase: "active" | "paused" | "blocked" | "complete"; + roundsStarted: number; + maxGoalRounds: number; + blockedReason?: { + code: string; + message: string; + }; + }; + activation: "armed" | "disarmed"; + }; + ralph: { + runId: string; + agentsStarted: number; + result: JsonValue; + }; + read: { + path: string; + offset: number; + lines: { + number: number; + text: string; + }[]; + totalLines: number; + }; + skill: { + name: string; + provider: string; + resourceBase?: { + kind: "directory"; + path: string; + } | { + kind: "url"; + url: string; + } | { + kind: "opaque"; + description: string; + }; + content: string; + }; + subagent: { + kind: "background"; + taskId: string; + } | { + kind: "foreground"; + runId: string; + output: JsonValue[]; + }; + subagent_fork: { + kind: "background"; + taskId: string; + } | { + kind: "foreground"; + runId: string; + output: JsonValue[]; + }; + task_kill: { + outcome: "cancellation-requested" | "already-finished"; + task: { + id: string; + kind: string; + label: string; + status: "running" | "stopping" | "completed" | "killed" | "failed"; + detail?: string; + startedAt: number; + finishedAt?: number; + }; + }; + task_list: ({ + id: string; + kind: string; + label: string; + status: "running" | "stopping" | "completed" | "killed" | "failed"; + detail?: string; + startedAt: number; + finishedAt?: number; + })[]; + task_output: { + text: string; + task: { + id: string; + kind: string; + label: string; + status: "running" | "stopping" | "completed" | "killed" | "failed"; + detail?: string; + startedAt: number; + finishedAt?: number; + }; + }; + todo_write: { + todos: ({ + content: string; + status: "pending" | "in_progress" | "completed"; + })[]; + counts: { + pending: number; + inProgress: number; + completed: number; + }; + }; + update_goal: { + goal: null; + } | { + goal: { + id: string; + revision: number; + objective: string; + phase: "active" | "paused" | "blocked" | "complete"; + roundsStarted: number; + maxGoalRounds: number; + blockedReason?: { + code: string; + message: string; + }; + }; + activation: "armed" | "disarmed"; + }; + workflow: { + runId: string; + agentsStarted: number; + result: JsonValue; + }; + write: { + path: string; + operation: "create" | "update"; + before: string | null; + after: string; + }; +} + +type ToolName = keyof ToolOutputMap + +declare class ToolCallError extends Error { + readonly name: "ToolCallError"; + readonly toolName: ToolName; +} + +declare const tools: { + [K in ToolName]: (args: ToolArgsMap[K]) => 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 79cb046a40..7ceeec4042 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 @@ -11,6 +11,7 @@ "description": "Questions to ask the user before continuing.", "items": { "type": "object", + "additionalProperties": true, "properties": { "id": { "type": "string", @@ -29,6 +30,7 @@ "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", @@ -381,6 +383,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -455,7 +458,7 @@ }, { "name": "workflow", - "description": "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.\n\nThe 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.\n\nScript-body hooks:\n- `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.\n- `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.\n- `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`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: 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.", + "description": "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.\n\nThe 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.\n\nScript-body hooks:\n- `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/oneOf — no 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.\n- `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.\n- `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`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: 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.", "parameters": { "type": "object", "properties": { @@ -466,6 +469,7 @@ "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -484,6 +488,7 @@ "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -515,7 +520,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/input.json b/examples/acp-agent/tests/snapshots/code-mode-turn/input.json index c6d4a1039e..03a3bca538 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/input.json +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/input.json @@ -2,6 +2,6 @@ "steps": [ { "op": "initialize" }, { "op": "newSession" }, - { "op": "prompt", "text": "Using ONE run_code program: call the bash tool twice — exactly `echo CODE_ONE` then exactly `echo CODE_TWO` — and return the two outputs joined with a plus sign. Then reply with that joined string only and stop." } + { "op": "prompt", "text": "Using ONE run_code program: call the bash tool twice — exactly `echo CODE_ONE` then exactly `echo CODE_TWO`. Inside that same program, console.log exactly `captured output`, then return the two outputs joined with a plus sign. Reply with that joined string only and stop." } ] } diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl index 88e4b7f75d..a7f4620a51 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl @@ -1,151 +1,685 @@ -{"type":"session","version":0,"id":"94cd1ae4-e1d1-4ec8-9d27-50a1f849b6b3","createdAt":1783611771392,"cwd":"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-BteTVR","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783611771394,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783611771394,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool twice — exactly `echo CODE_ONE` then exactly `echo CODE_TWO` — and return the two outputs joined with a plus sign. Then reply with that joined string only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783611771394,"data":{"title":"Using ONE run_code program: call","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":1783611771396,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783611771396,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":1783611771869,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":6,"time":1783611771869,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":7,"time":1783611771978,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":8,"time":1783611772007,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":9,"time":1783611772008,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":10,"time":1783611772036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":11,"time":1783611772036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":12,"time":1783611772036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_code"}}} -{"type":"assistant/chunk","seq":13,"time":1783611772036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" program"}}} -{"type":"assistant/chunk","seq":14,"time":1783611772036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":15,"time":1783611772089,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" calls"}}} -{"type":"assistant/chunk","seq":16,"time":1783611772089,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} -{"type":"assistant/chunk","seq":17,"time":1783611772089,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" twice"}}} -{"type":"assistant/chunk","seq":18,"time":1783611772096,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":19,"time":1783611772124,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} -{"type":"assistant/chunk","seq":20,"time":1783611772153,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}} -{"type":"assistant/chunk","seq":21,"time":1783611772183,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":22,"time":1783611772183,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" two"}}} -{"type":"assistant/chunk","seq":23,"time":1783611772211,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" outputs"}}} -{"type":"assistant/chunk","seq":24,"time":1783611772212,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" joined"}}} -{"type":"assistant/chunk","seq":25,"time":1783611772212,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":26,"time":1783611772212,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":27,"time":1783611772212,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" plus"}}} -{"type":"assistant/chunk","seq":28,"time":1783611772212,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sign"}}} -{"type":"assistant/chunk","seq":29,"time":1783611772241,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":30,"time":1783611772241,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":31,"time":1783611772241,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":32,"time":1783611772241,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} -{"type":"assistant/chunk","seq":33,"time":1783611772270,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} -{"type":"assistant/chunk","seq":34,"time":1783611772270,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":35,"time":1783611772361,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":36,"time":1783611772361,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":37,"time":1783611772361,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":38,"time":1783611772362,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":39,"time":1783611772390,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"code"}}} -{"type":"assistant/chunk","seq":40,"time":1783611772390,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":41,"time":1783611772390,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":42,"time":1783611772390,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":43,"time":1783611772420,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"const"}}} -{"type":"assistant/chunk","seq":44,"time":1783611772420,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" out"}}} -{"type":"assistant/chunk","seq":45,"time":1783611772420,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"1"}}} -{"type":"assistant/chunk","seq":46,"time":1783611772420,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" ="}}} -{"type":"assistant/chunk","seq":47,"time":1783611772421,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" await"}}} -{"type":"assistant/chunk","seq":48,"time":1783611772421,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" tools"}}} -{"type":"assistant/chunk","seq":49,"time":1783611772449,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":".b"}}} -{"type":"assistant/chunk","seq":50,"time":1783611772449,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"ash"}}} -{"type":"assistant/chunk","seq":51,"time":1783611772449,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"({"}}} -{"type":"assistant/chunk","seq":52,"time":1783611772449,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" command"}}} -{"type":"assistant/chunk","seq":53,"time":1783611772478,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":":"}}} -{"type":"assistant/chunk","seq":54,"time":1783611772479,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" \\\""}}} -{"type":"assistant/chunk","seq":55,"time":1783611772479,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":56,"time":1783611772479,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" CODE"}}} -{"type":"assistant/chunk","seq":57,"time":1783611772479,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"_"}}} -{"type":"assistant/chunk","seq":58,"time":1783611772508,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"ONE"}}} -{"type":"assistant/chunk","seq":59,"time":1783611772510,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\\\","}}} -{"type":"assistant/chunk","seq":60,"time":1783611772510,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" description"}}} -{"type":"assistant/chunk","seq":61,"time":1783611772510,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":":"}}} -{"type":"assistant/chunk","seq":62,"time":1783611772510,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" \\\""}}} -{"type":"assistant/chunk","seq":63,"time":1783611772510,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"First"}}} -{"type":"assistant/chunk","seq":64,"time":1783611772538,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" echo"}}} -{"type":"assistant/chunk","seq":65,"time":1783611772538,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\\\""}}} -{"type":"assistant/chunk","seq":66,"time":1783611772566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" });\\n"}}} -{"type":"assistant/chunk","seq":67,"time":1783611772567,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"const"}}} -{"type":"assistant/chunk","seq":68,"time":1783611772567,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" out"}}} -{"type":"assistant/chunk","seq":69,"time":1783611772567,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"2"}}} -{"type":"assistant/chunk","seq":70,"time":1783611772567,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" ="}}} -{"type":"assistant/chunk","seq":71,"time":1783611772567,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" await"}}} -{"type":"assistant/chunk","seq":72,"time":1783611772597,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" tools"}}} -{"type":"assistant/chunk","seq":73,"time":1783611772597,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":".b"}}} -{"type":"assistant/chunk","seq":74,"time":1783611772597,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"ash"}}} -{"type":"assistant/chunk","seq":75,"time":1783611772598,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"({"}}} -{"type":"assistant/chunk","seq":76,"time":1783611772598,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" command"}}} -{"type":"assistant/chunk","seq":77,"time":1783611772598,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":":"}}} -{"type":"assistant/chunk","seq":78,"time":1783611772625,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" \\\""}}} -{"type":"assistant/chunk","seq":79,"time":1783611772625,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":80,"time":1783611772625,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" CODE"}}} -{"type":"assistant/chunk","seq":81,"time":1783611772626,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"_T"}}} -{"type":"assistant/chunk","seq":82,"time":1783611772626,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"WO"}}} -{"type":"assistant/chunk","seq":83,"time":1783611772626,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\\\","}}} -{"type":"assistant/chunk","seq":84,"time":1783611772654,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" description"}}} -{"type":"assistant/chunk","seq":85,"time":1783611772655,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":":"}}} -{"type":"assistant/chunk","seq":86,"time":1783611772655,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" \\\""}}} -{"type":"assistant/chunk","seq":87,"time":1783611772655,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"Second"}}} -{"type":"assistant/chunk","seq":88,"time":1783611772655,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" echo"}}} -{"type":"assistant/chunk","seq":89,"time":1783611772655,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\\\""}}} -{"type":"assistant/chunk","seq":90,"time":1783611772684,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" });\\n"}}} -{"type":"assistant/chunk","seq":91,"time":1783611772685,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"return"}}} -{"type":"assistant/chunk","seq":92,"time":1783611772685,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" out"}}} -{"type":"assistant/chunk","seq":93,"time":1783611772713,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"1"}}} -{"type":"assistant/chunk","seq":94,"time":1783611772714,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":".trim"}}} -{"type":"assistant/chunk","seq":95,"time":1783611772714,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"()"}}} -{"type":"assistant/chunk","seq":96,"time":1783611772714,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" +"}}} -{"type":"assistant/chunk","seq":97,"time":1783611772714,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" \\\"+"}}} -{"type":"assistant/chunk","seq":98,"time":1783611772744,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\\\""}}} -{"type":"assistant/chunk","seq":99,"time":1783611772744,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" +"}}} -{"type":"assistant/chunk","seq":100,"time":1783611772745,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" out"}}} -{"type":"assistant/chunk","seq":101,"time":1783611772745,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"2"}}} -{"type":"assistant/chunk","seq":102,"time":1783611772745,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":".trim"}}} -{"type":"assistant/chunk","seq":103,"time":1783611772745,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"();"}}} -{"type":"assistant/chunk","seq":104,"time":1783611772772,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":105,"time":1783611772773,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":106,"time":1783611772836,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants a single run_code program that calls bash twice, then returns the two outputs joined with a plus sign. Let me write this."}}}} -{"type":"assistant/chunk","seq":107,"time":1783611772836,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","arguments":"{\"code\": \"const out1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"First echo\\\" });\\nconst out2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Second echo\\\" });\\nreturn out1.trim() + \\\"+\\\" + out2.trim();\"}"}}}} -{"type":"assistant/chunk","seq":108,"time":1783611772836,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3009,"outputTokens":133,"cacheReadTokens":0,"reasoningTokens":29}}}} -{"type":"assistant/chunk","seq":109,"time":1783611772836,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":110,"time":1783611772840,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants a single run_code program that calls bash twice, then returns the two outputs joined with a plus sign. Let me write this."},{"type":"tool-call","id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","arguments":"{\"code\": \"const out1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"First echo\\\" });\\nconst out2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Second echo\\\" });\\nreturn out1.trim() + \\\"+\\\" + out2.trim();\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3009,"outputTokens":133,"cacheReadTokens":0,"reasoningTokens":29}},"sourceEventSeqs":[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,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109],"surfaceOp":"append"} -{"type":"tool/call","seq":111,"time":1783611772840,"data":{"turn":1,"step":1,"callId":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","arguments":"{\"code\": \"const out1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"First echo\\\" });\\nconst out2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Second echo\\\" });\\nreturn out1.trim() + \\\"+\\\" + out2.trim();\"}"}} -{"type":"tool/code-dispatch","seq":112,"time":1783611772933,"data":{"parentCallId":"call_00_DRxnM6R1TThfDwcudW0f2050","subCallId":"call_00_DRxnM6R1TThfDwcudW0f2050:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"First echo"},"isError":false,"resultSummary":"CODE_ONE\n"}} -{"type":"tool/code-dispatch","seq":113,"time":1783611772936,"data":{"parentCallId":"call_00_DRxnM6R1TThfDwcudW0f2050","subCallId":"call_00_DRxnM6R1TThfDwcudW0f2050:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Second echo"},"isError":false,"resultSummary":"CODE_TWO\n"}} -{"type":"tool/result","seq":114,"time":1783611772937,"data":{"turn":1,"step":1,"callId":"call_00_DRxnM6R1TThfDwcudW0f2050","content":[{"type":"text","text":"CODE_ONE+CODE_TWO"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[111],"surfaceOp":"append"} -{"type":"step/end","seq":115,"time":1783611772938,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":116,"time":1783611772938,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":117,"time":1783611773376,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":118,"time":1783611773376,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":119,"time":1783611773480,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} -{"type":"assistant/chunk","seq":120,"time":1783611773511,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":121,"time":1783611773512,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":122,"time":1783611773540,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} -{"type":"assistant/chunk","seq":123,"time":1783611773541,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":124,"time":1783611773541,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":125,"time":1783611773569,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} -{"type":"assistant/chunk","seq":126,"time":1783611773570,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" for"}}} -{"type":"assistant/chunk","seq":127,"time":1783611773570,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} -{"type":"assistant/chunk","seq":128,"time":1783611773597,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" CODE"}}} -{"type":"assistant/chunk","seq":129,"time":1783611773597,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} -{"type":"assistant/chunk","seq":130,"time":1783611773597,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":131,"time":1783611773626,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"+"}}} -{"type":"assistant/chunk","seq":132,"time":1783611773627,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} -{"type":"assistant/chunk","seq":133,"time":1783611773627,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_T"}}} -{"type":"assistant/chunk","seq":134,"time":1783611773627,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} -{"type":"assistant/chunk","seq":135,"time":1783611773654,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":136,"time":1783611773655,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"CODE"}}} -{"type":"assistant/chunk","seq":137,"time":1783611773655,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_"}}} -{"type":"assistant/chunk","seq":138,"time":1783611773656,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":139,"time":1783611773656,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"+"}}} -{"type":"assistant/chunk","seq":140,"time":1783611773656,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"CODE"}}} -{"type":"assistant/chunk","seq":141,"time":1783611773657,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_T"}}} -{"type":"assistant/chunk","seq":142,"time":1783611773685,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"WO"}}} -{"type":"assistant/chunk","seq":143,"time":1783611773686,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The output is exactly what the user asked for: CODE_ONE+CODE_TWO"}}}} -{"type":"assistant/chunk","seq":144,"time":1783611773686,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CODE_ONE+CODE_TWO"}}}} -{"type":"assistant/chunk","seq":145,"time":1783611773686,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":89,"outputTokens":25,"cacheReadTokens":3072,"reasoningTokens":17}}}} -{"type":"assistant/chunk","seq":146,"time":1783611773686,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":147,"time":1783611773687,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The output is exactly what the user asked for: CODE_ONE+CODE_TWO"},{"type":"text","text":"CODE_ONE+CODE_TWO"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":89,"outputTokens":25,"cacheReadTokens":3072,"reasoningTokens":17}},"sourceEventSeqs":[117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146],"surfaceOp":"append"} -{"type":"step/end","seq":148,"time":1783611773687,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":149,"time":1783611773687,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"bfa65aa9-f8f8-4b91-af4b-9653cee8fc19","createdAt":1784629671301,"cwd":"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QAp4c9","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1784629671304,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1784629671305,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool twice — exactly `echo CODE_ONE` then exactly `echo CODE_TWO`. Inside that same program, console.log exactly `captured output`, then return the two outputs joined with a plus sign. Reply with that joined string only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1784629671305,"data":{"title":"Using ONE run_code program: call","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1784629671311,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1784629671312,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1784629671745,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":6,"time":1784629671746,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} +{"type":"assistant/chunk","seq":7,"time":1784629671954,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1784629671983,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" carefully"}}} +{"type":"assistant/chunk","seq":9,"time":1784629672017,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":10,"time":1784629672017,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":11,"time":1784629672039,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" instructions"}}} +{"type":"assistant/chunk","seq":12,"time":1784629672040,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":13,"time":1784629672040,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":14,"time":1784629672040,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":15,"time":1784629672040,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":16,"time":1784629672040,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} +{"type":"assistant/chunk","seq":17,"time":1784629672068,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ONE"}}} +{"type":"assistant/chunk","seq":18,"time":1784629672068,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":19,"time":1784629672068,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_code"}}} +{"type":"assistant/chunk","seq":20,"time":1784629672068,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" program"}}} +{"type":"assistant/chunk","seq":21,"time":1784629672096,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":22,"time":1784629672096,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":\n\n"}}} +{"type":"assistant/chunk","seq":23,"time":1784629672096,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} +{"type":"assistant/chunk","seq":24,"time":1784629672096,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":25,"time":1784629672096,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Calls"}}} +{"type":"assistant/chunk","seq":26,"time":1784629672125,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":27,"time":1784629672125,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":28,"time":1784629672125,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":29,"time":1784629672153,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":30,"time":1784629672153,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" CODE"}}} +{"type":"assistant/chunk","seq":31,"time":1784629672153,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} +{"type":"assistant/chunk","seq":32,"time":1784629672153,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":33,"time":1784629672153,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":34,"time":1784629672187,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" -"}}} +{"type":"assistant/chunk","seq":35,"time":1784629672187,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":36,"time":1784629672210,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":37,"time":1784629672237,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":38,"time":1784629672238,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":39,"time":1784629672265,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":40,"time":1784629672294,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" CODE"}}} +{"type":"assistant/chunk","seq":41,"time":1784629672294,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} +{"type":"assistant/chunk","seq":42,"time":1784629672294,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":43,"time":1784629672295,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"\n"}}} +{"type":"assistant/chunk","seq":44,"time":1784629672295,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} +{"type":"assistant/chunk","seq":45,"time":1784629672295,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":46,"time":1784629672327,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Calls"}}} +{"type":"assistant/chunk","seq":47,"time":1784629672327,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":48,"time":1784629672327,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":49,"time":1784629672327,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":50,"time":1784629672327,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":51,"time":1784629672327,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" CODE"}}} +{"type":"assistant/chunk","seq":52,"time":1784629672351,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_T"}}} +{"type":"assistant/chunk","seq":53,"time":1784629672351,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} +{"type":"assistant/chunk","seq":54,"time":1784629672351,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":55,"time":1784629672351,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" -"}}} +{"type":"assistant/chunk","seq":56,"time":1784629672351,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":57,"time":1784629672351,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":58,"time":1784629672382,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":59,"time":1784629672382,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":60,"time":1784629672382,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":61,"time":1784629672383,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" CODE"}}} +{"type":"assistant/chunk","seq":62,"time":1784629672383,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_T"}}} +{"type":"assistant/chunk","seq":63,"time":1784629672383,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} +{"type":"assistant/chunk","seq":64,"time":1784629672410,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"\n"}}} +{"type":"assistant/chunk","seq":65,"time":1784629672410,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"3"}}} +{"type":"assistant/chunk","seq":66,"time":1784629672410,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":67,"time":1784629672411,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Inside"}}} +{"type":"assistant/chunk","seq":68,"time":1784629672438,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":69,"time":1784629672468,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" same"}}} +{"type":"assistant/chunk","seq":70,"time":1784629672468,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" program"}}} +{"type":"assistant/chunk","seq":71,"time":1784629672468,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":72,"time":1784629672468,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" console"}}} +{"type":"assistant/chunk","seq":73,"time":1784629672468,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".log"}}} +{"type":"assistant/chunk","seq":74,"time":1784629672468,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":75,"time":1784629672495,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":76,"time":1784629672496,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"capt"}}} +{"type":"assistant/chunk","seq":77,"time":1784629672496,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ured"}}} +{"type":"assistant/chunk","seq":78,"time":1784629672496,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":79,"time":1784629672525,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`\n"}}} +{"type":"assistant/chunk","seq":80,"time":1784629672525,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"4"}}} +{"type":"assistant/chunk","seq":81,"time":1784629672525,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":82,"time":1784629672525,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Return"}}} +{"type":"assistant/chunk","seq":83,"time":1784629672552,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":84,"time":1784629672552,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" two"}}} +{"type":"assistant/chunk","seq":85,"time":1784629672553,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" outputs"}}} +{"type":"assistant/chunk","seq":86,"time":1784629672553,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" joined"}}} +{"type":"assistant/chunk","seq":87,"time":1784629672553,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":88,"time":1784629672553,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":89,"time":1784629672581,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" plus"}}} +{"type":"assistant/chunk","seq":90,"time":1784629672581,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sign"}}} +{"type":"assistant/chunk","seq":91,"time":1784629672581,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\n\n"}}} +{"type":"assistant/chunk","seq":92,"time":1784629672581,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} +{"type":"assistant/chunk","seq":93,"time":1784629672610,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":94,"time":1784629672610,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" look"}}} +{"type":"assistant/chunk","seq":95,"time":1784629672610,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" at"}}} +{"type":"assistant/chunk","seq":96,"time":1784629672610,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":97,"time":1784629672638,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":98,"time":1784629672639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":99,"time":1784629672639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" signature"}}} +{"type":"assistant/chunk","seq":100,"time":1784629672668,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" carefully"}}} +{"type":"assistant/chunk","seq":101,"time":1784629672668,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":102,"time":1784629672696,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":103,"time":1784629672696,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":104,"time":1784629672726,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"command"}}} +{"type":"assistant/chunk","seq":105,"time":1784629672726,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":106,"time":1784629672726,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" parameter"}}} +{"type":"assistant/chunk","seq":107,"time":1784629672727,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":108,"time":1784629672758,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":109,"time":1784629672758,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":110,"time":1784629672786,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":111,"time":1784629672786,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":112,"time":1784629672814,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" execute"}}} +{"type":"assistant/chunk","seq":113,"time":1784629672814,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":114,"time":1784629672814,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" So"}}} +{"type":"assistant/chunk","seq":115,"time":1784629672843,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":116,"time":1784629672843,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'d"}}} +{"type":"assistant/chunk","seq":117,"time":1784629672871,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" pass"}}} +{"type":"assistant/chunk","seq":118,"time":1784629672871,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":119,"time":1784629672900,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":120,"time":1784629672930,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" CODE"}}} +{"type":"assistant/chunk","seq":121,"time":1784629672931,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} +{"type":"assistant/chunk","seq":122,"time":1784629672931,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":123,"time":1784629672931,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":124,"time":1784629672931,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} +{"type":"assistant/chunk","seq":125,"time":1784629672958,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":126,"time":1784629672958,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":127,"time":1784629672959,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\n\n"}}} +{"type":"assistant/chunk","seq":128,"time":1784629672959,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"But"}}} +{"type":"assistant/chunk","seq":129,"time":1784629672959,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wait"}}} +{"type":"assistant/chunk","seq":130,"time":1784629672989,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" -"}}} +{"type":"assistant/chunk","seq":131,"time":1784629672989,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":132,"time":1784629672989,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":133,"time":1784629673016,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":134,"time":1784629673016,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" be"}}} +{"type":"assistant/chunk","seq":135,"time":1784629673016,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" careful"}}} +{"type":"assistant/chunk","seq":136,"time":1784629673044,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" about"}}} +{"type":"assistant/chunk","seq":137,"time":1784629673044,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} +{"type":"assistant/chunk","seq":138,"time":1784629673044,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":139,"time":1784629673073,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":140,"time":1784629673073,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" looks"}}} +{"type":"assistant/chunk","seq":141,"time":1784629673103,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" like"}}} +{"type":"assistant/chunk","seq":142,"time":1784629673103,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":143,"time":1784629673103,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":144,"time":1784629673103,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":145,"time":1784629673130,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":146,"time":1784629673130,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}} +{"type":"assistant/chunk","seq":147,"time":1784629673159,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" stdout"}}} +{"type":"assistant/chunk","seq":148,"time":1784629673159,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"/st"}}} +{"type":"assistant/chunk","seq":149,"time":1784629673187,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"derr"}}} +{"type":"assistant/chunk","seq":150,"time":1784629673187,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" objects"}}} +{"type":"assistant/chunk","seq":151,"time":1784629673217,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":152,"time":1784629673218,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":153,"time":1784629673218,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":154,"time":1784629673218,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" check"}}} +{"type":"assistant/chunk","seq":155,"time":1784629673245,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":156,"time":1784629673245,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":157,"time":1784629673275,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" type"}}} +{"type":"assistant/chunk","seq":158,"time":1784629673302,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":\n\n"}}} +{"type":"assistant/chunk","seq":159,"time":1784629673302,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"```"}}} +{"type":"assistant/chunk","seq":160,"time":1784629673302,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ts"}}} +{"type":"assistant/chunk","seq":161,"time":1784629673303,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\n"}}} +{"type":"assistant/chunk","seq":162,"time":1784629673303,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"bash"}}} +{"type":"assistant/chunk","seq":163,"time":1784629673335,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":164,"time":1784629673335,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" {\n"}}} +{"type":"assistant/chunk","seq":165,"time":1784629673335,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":166,"time":1784629673360,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" kind"}}} +{"type":"assistant/chunk","seq":167,"time":1784629673360,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":168,"time":1784629673360,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":169,"time":1784629673388,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"fore"}}} +{"type":"assistant/chunk","seq":170,"time":1784629673421,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ground"}}} +{"type":"assistant/chunk","seq":171,"time":1784629673421,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":172,"time":1784629673421,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" |"}}} +{"type":"assistant/chunk","seq":173,"time":1784629673422,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":174,"time":1784629673422,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"background"}}} +{"type":"assistant/chunk","seq":175,"time":1784629673448,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\";\n"}}} +{"type":"assistant/chunk","seq":176,"time":1784629673448,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":177,"time":1784629673448,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" //"}}} +{"type":"assistant/chunk","seq":178,"time":1784629673477,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" if"}}} +{"type":"assistant/chunk","seq":179,"time":1784629673506,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" foreground"}}} +{"type":"assistant/chunk","seq":180,"time":1784629673507,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":\n"}}} +{"type":"assistant/chunk","seq":181,"time":1784629673507,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":182,"time":1784629673534,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exit"}}} +{"type":"assistant/chunk","seq":183,"time":1784629673534,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Code"}}} +{"type":"assistant/chunk","seq":184,"time":1784629673534,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":185,"time":1784629673534,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" number"}}} +{"type":"assistant/chunk","seq":186,"time":1784629673534,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" |"}}} +{"type":"assistant/chunk","seq":187,"time":1784629673562,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" null"}}} +{"type":"assistant/chunk","seq":188,"time":1784629673562,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":";\n"}}} +{"type":"assistant/chunk","seq":189,"time":1784629673562,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":190,"time":1784629673562,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" stdout"}}} +{"type":"assistant/chunk","seq":191,"time":1784629673590,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":192,"time":1784629673590,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" {"}}} +{"type":"assistant/chunk","seq":193,"time":1784629673618,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" text"}}} +{"type":"assistant/chunk","seq":194,"time":1784629673619,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":195,"time":1784629673619,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" string"}}} +{"type":"assistant/chunk","seq":196,"time":1784629673619,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":";"}}} +{"type":"assistant/chunk","seq":197,"time":1784629673619,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" truncated"}}} +{"type":"assistant/chunk","seq":198,"time":1784629673647,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":199,"time":1784629673647,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" boolean"}}} +{"type":"assistant/chunk","seq":200,"time":1784629673647,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":";"}}} +{"type":"assistant/chunk","seq":201,"time":1784629673647,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" spill"}}} +{"type":"assistant/chunk","seq":202,"time":1784629673675,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Path"}}} +{"type":"assistant/chunk","seq":203,"time":1784629673676,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"?:"}}} +{"type":"assistant/chunk","seq":204,"time":1784629673676,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" string"}}} +{"type":"assistant/chunk","seq":205,"time":1784629673676,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":";"}}} +{"type":"assistant/chunk","seq":206,"time":1784629673728,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" };\n"}}} +{"type":"assistant/chunk","seq":207,"time":1784629673728,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":208,"time":1784629673728,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" st"}}} +{"type":"assistant/chunk","seq":209,"time":1784629673728,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"derr"}}} +{"type":"assistant/chunk","seq":210,"time":1784629673728,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":211,"time":1784629673728,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" {"}}} +{"type":"assistant/chunk","seq":212,"time":1784629673728,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" text"}}} +{"type":"assistant/chunk","seq":213,"time":1784629673732,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":214,"time":1784629673733,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" string"}}} +{"type":"assistant/chunk","seq":215,"time":1784629673733,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":";"}}} +{"type":"assistant/chunk","seq":216,"time":1784629673733,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ..."}}} +{"type":"assistant/chunk","seq":217,"time":1784629673782,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" };\n"}}} +{"type":"assistant/chunk","seq":218,"time":1784629673782,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":219,"time":1784629673782,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ...\n"}}} +{"type":"assistant/chunk","seq":220,"time":1784629673805,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"}\n"}}} +{"type":"assistant/chunk","seq":221,"time":1784629673805,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"```\n\n"}}} +{"type":"assistant/chunk","seq":222,"time":1784629673805,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"So"}}} +{"type":"assistant/chunk","seq":223,"time":1784629673805,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":224,"time":1784629673805,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":225,"time":1784629673838,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":226,"time":1784629673838,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" extract"}}} +{"type":"assistant/chunk","seq":227,"time":1784629673838,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":228,"time":1784629673866,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"std"}}} +{"type":"assistant/chunk","seq":229,"time":1784629673867,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"out"}}} +{"type":"assistant/chunk","seq":230,"time":1784629673867,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".text"}}} +{"type":"assistant/chunk","seq":231,"time":1784629673894,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":232,"time":1784629673894,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" from"}}} +{"type":"assistant/chunk","seq":233,"time":1784629673894,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" each"}}} +{"type":"assistant/chunk","seq":234,"time":1784629673894,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" call"}}} +{"type":"assistant/chunk","seq":235,"time":1784629673983,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\n\n"}}} +{"type":"assistant/chunk","seq":236,"time":1784629673983,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} +{"type":"assistant/chunk","seq":237,"time":1784629673983,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":238,"time":1784629673983,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" also"}}} +{"type":"assistant/chunk","seq":239,"time":1784629673983,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" make"}}} +{"type":"assistant/chunk","seq":240,"time":1784629673983,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sure"}}} +{"type":"assistant/chunk","seq":241,"time":1784629673983,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" about"}}} +{"type":"assistant/chunk","seq":242,"time":1784629674007,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":243,"time":1784629674007,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":244,"time":1784629674035,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"description"}}} +{"type":"assistant/chunk","seq":245,"time":1784629674036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":246,"time":1784629674036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" parameter"}}} +{"type":"assistant/chunk","seq":247,"time":1784629674064,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" -"}}} +{"type":"assistant/chunk","seq":248,"time":1784629674064,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":249,"time":1784629674064,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} +{"type":"assistant/chunk","seq":250,"time":1784629674064,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" required"}}} +{"type":"assistant/chunk","seq":251,"time":1784629674092,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":252,"time":1784629674124,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} +{"type":"assistant/chunk","seq":253,"time":1784629674153,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" be"}}} +{"type":"assistant/chunk","seq":254,"time":1784629674153,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":255,"time":1784629674181,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"5"}}} +{"type":"assistant/chunk","seq":256,"time":1784629674181,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-"}}} +{"type":"assistant/chunk","seq":257,"time":1784629674181,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"10"}}} +{"type":"assistant/chunk","seq":258,"time":1784629674210,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" words"}}} +{"type":"assistant/chunk","seq":259,"time":1784629674210,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":260,"time":1784629674239,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" describing"}}} +{"type":"assistant/chunk","seq":261,"time":1784629674239,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":262,"time":1784629674270,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":263,"time":1784629674270,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\n\n"}}} +{"type":"assistant/chunk","seq":264,"time":1784629674270,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} +{"type":"assistant/chunk","seq":265,"time":1784629674270,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":266,"time":1784629674270,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} +{"type":"assistant/chunk","seq":267,"time":1784629674296,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":268,"time":1784629674296,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" code"}}} +{"type":"assistant/chunk","seq":269,"time":1784629674327,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":\n\n"}}} +{"type":"assistant/chunk","seq":270,"time":1784629674327,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"```"}}} +{"type":"assistant/chunk","seq":271,"time":1784629674327,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ts"}}} +{"type":"assistant/chunk","seq":272,"time":1784629674327,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\n"}}} +{"type":"assistant/chunk","seq":273,"time":1784629674327,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"const"}}} +{"type":"assistant/chunk","seq":274,"time":1784629674327,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" r"}}} +{"type":"assistant/chunk","seq":275,"time":1784629674354,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} +{"type":"assistant/chunk","seq":276,"time":1784629674354,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ="}}} +{"type":"assistant/chunk","seq":277,"time":1784629674354,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" await"}}} +{"type":"assistant/chunk","seq":278,"time":1784629674354,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} +{"type":"assistant/chunk","seq":279,"time":1784629674354,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".b"}}} +{"type":"assistant/chunk","seq":280,"time":1784629674354,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ash"}}} +{"type":"assistant/chunk","seq":281,"time":1784629674382,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"({"}}} +{"type":"assistant/chunk","seq":282,"time":1784629674382,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":283,"time":1784629674382,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":284,"time":1784629674382,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":285,"time":1784629674382,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":286,"time":1784629674382,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" CODE"}}} +{"type":"assistant/chunk","seq":287,"time":1784629674411,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} +{"type":"assistant/chunk","seq":288,"time":1784629674411,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":289,"time":1784629674411,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\","}}} +{"type":"assistant/chunk","seq":290,"time":1784629674411,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" description"}}} +{"type":"assistant/chunk","seq":291,"time":1784629674411,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":292,"time":1784629674411,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":293,"time":1784629674440,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"E"}}} +{"type":"assistant/chunk","seq":294,"time":1784629674440,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"cho"}}} +{"type":"assistant/chunk","seq":295,"time":1784629674472,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" CODE"}}} +{"type":"assistant/chunk","seq":296,"time":1784629674472,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} +{"type":"assistant/chunk","seq":297,"time":1784629674472,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":298,"time":1784629674472,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":299,"time":1784629674501,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" stdout"}}} +{"type":"assistant/chunk","seq":300,"time":1784629674501,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":301,"time":1784629674501,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" });\n"}}} +{"type":"assistant/chunk","seq":302,"time":1784629674501,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"const"}}} +{"type":"assistant/chunk","seq":303,"time":1784629674501,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" r"}}} +{"type":"assistant/chunk","seq":304,"time":1784629674529,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} +{"type":"assistant/chunk","seq":305,"time":1784629674529,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ="}}} +{"type":"assistant/chunk","seq":306,"time":1784629674529,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" await"}}} +{"type":"assistant/chunk","seq":307,"time":1784629674529,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} +{"type":"assistant/chunk","seq":308,"time":1784629674529,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".b"}}} +{"type":"assistant/chunk","seq":309,"time":1784629674529,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ash"}}} +{"type":"assistant/chunk","seq":310,"time":1784629674561,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"({"}}} +{"type":"assistant/chunk","seq":311,"time":1784629674561,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":312,"time":1784629674561,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":313,"time":1784629674561,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":314,"time":1784629674561,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":315,"time":1784629674561,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" CODE"}}} +{"type":"assistant/chunk","seq":316,"time":1784629674590,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_T"}}} +{"type":"assistant/chunk","seq":317,"time":1784629674590,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} +{"type":"assistant/chunk","seq":318,"time":1784629674590,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\","}}} +{"type":"assistant/chunk","seq":319,"time":1784629674590,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" description"}}} +{"type":"assistant/chunk","seq":320,"time":1784629674590,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":321,"time":1784629674591,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":322,"time":1784629674620,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"E"}}} +{"type":"assistant/chunk","seq":323,"time":1784629674620,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"cho"}}} +{"type":"assistant/chunk","seq":324,"time":1784629674620,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" CODE"}}} +{"type":"assistant/chunk","seq":325,"time":1784629674620,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_T"}}} +{"type":"assistant/chunk","seq":326,"time":1784629674620,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} +{"type":"assistant/chunk","seq":327,"time":1784629674620,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":328,"time":1784629674650,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" stdout"}}} +{"type":"assistant/chunk","seq":329,"time":1784629674651,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":330,"time":1784629674651,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" });\n"}}} +{"type":"assistant/chunk","seq":331,"time":1784629674651,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"console"}}} +{"type":"assistant/chunk","seq":332,"time":1784629674651,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".log"}}} +{"type":"assistant/chunk","seq":333,"time":1784629674709,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"(\""}}} +{"type":"assistant/chunk","seq":334,"time":1784629674709,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"capt"}}} +{"type":"assistant/chunk","seq":335,"time":1784629674709,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ured"}}} +{"type":"assistant/chunk","seq":336,"time":1784629674709,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":337,"time":1784629674709,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\");\n"}}} +{"type":"assistant/chunk","seq":338,"time":1784629674709,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"return"}}} +{"type":"assistant/chunk","seq":339,"time":1784629674712,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" r"}}} +{"type":"assistant/chunk","seq":340,"time":1784629674741,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} +{"type":"assistant/chunk","seq":341,"time":1784629674741,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".stdout"}}} +{"type":"assistant/chunk","seq":342,"time":1784629674741,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".text"}}} +{"type":"assistant/chunk","seq":343,"time":1784629674770,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".trim"}}} +{"type":"assistant/chunk","seq":344,"time":1784629674770,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"()"}}} +{"type":"assistant/chunk","seq":345,"time":1784629674770,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" +"}}} +{"type":"assistant/chunk","seq":346,"time":1784629674770,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \"+"}}} +{"type":"assistant/chunk","seq":347,"time":1784629674798,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":348,"time":1784629674798,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" +"}}} +{"type":"assistant/chunk","seq":349,"time":1784629674799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" r"}}} +{"type":"assistant/chunk","seq":350,"time":1784629674799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} +{"type":"assistant/chunk","seq":351,"time":1784629674799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".stdout"}}} +{"type":"assistant/chunk","seq":352,"time":1784629674799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".text"}}} +{"type":"assistant/chunk","seq":353,"time":1784629674827,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".trim"}}} +{"type":"assistant/chunk","seq":354,"time":1784629674827,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"();\n"}}} +{"type":"assistant/chunk","seq":355,"time":1784629674827,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"```\n\n"}}} +{"type":"assistant/chunk","seq":356,"time":1784629674827,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} +{"type":"assistant/chunk","seq":357,"time":1784629674827,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":358,"time":1784629674827,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" think"}}} +{"type":"assistant/chunk","seq":359,"time":1784629674855,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" about"}}} +{"type":"assistant/chunk","seq":360,"time":1784629674855,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":361,"time":1784629674883,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":362,"time":1784629674883,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" format"}}} +{"type":"assistant/chunk","seq":363,"time":1784629674911,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":364,"time":1784629674911,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":365,"time":1784629674911,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":366,"time":1784629674940,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" CODE"}}} +{"type":"assistant/chunk","seq":367,"time":1784629674940,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} +{"type":"assistant/chunk","seq":368,"time":1784629674940,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":369,"time":1784629674941,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":370,"time":1784629674941,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" will"}}} +{"type":"assistant/chunk","seq":371,"time":1784629674969,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":372,"time":1784629674969,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":373,"time":1784629675000,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} +{"type":"assistant/chunk","seq":374,"time":1784629675000,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} +{"type":"assistant/chunk","seq":375,"time":1784629675000,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":376,"time":1784629675000,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\\n"}}} +{"type":"assistant/chunk","seq":377,"time":1784629675000,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`."}}} +{"type":"assistant/chunk","seq":378,"time":1784629675025,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" So"}}} +{"type":"assistant/chunk","seq":379,"time":1784629675055,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":380,"time":1784629675084,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"r"}}} +{"type":"assistant/chunk","seq":381,"time":1784629675084,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} +{"type":"assistant/chunk","seq":382,"time":1784629675084,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".stdout"}}} +{"type":"assistant/chunk","seq":383,"time":1784629675111,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".text"}}} +{"type":"assistant/chunk","seq":384,"time":1784629675111,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":385,"time":1784629675145,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" would"}}} +{"type":"assistant/chunk","seq":386,"time":1784629675145,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" be"}}} +{"type":"assistant/chunk","seq":387,"time":1784629675145,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":388,"time":1784629675145,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":389,"time":1784629675171,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} +{"type":"assistant/chunk","seq":390,"time":1784629675171,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} +{"type":"assistant/chunk","seq":391,"time":1784629675171,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":392,"time":1784629675172,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\\n"}}} +{"type":"assistant/chunk","seq":393,"time":1784629675172,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":394,"time":1784629675172,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":395,"time":1784629675199,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":396,"time":1784629675199,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" after"}}} +{"type":"assistant/chunk","seq":397,"time":1784629675229,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" trim"}}} +{"type":"assistant/chunk","seq":398,"time":1784629675257,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":399,"time":1784629675257,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} +{"type":"assistant/chunk","seq":400,"time":1784629675257,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":401,"time":1784629675285,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":402,"time":1784629675286,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} +{"type":"assistant/chunk","seq":403,"time":1784629675286,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} +{"type":"assistant/chunk","seq":404,"time":1784629675286,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":405,"time":1784629675286,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":406,"time":1784629675318,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`.\n\n"}}} +{"type":"assistant/chunk","seq":407,"time":1784629675318,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"So"}}} +{"type":"assistant/chunk","seq":408,"time":1784629675360,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":409,"time":1784629675361,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" joined"}}} +{"type":"assistant/chunk","seq":410,"time":1784629675361,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" string"}}} +{"type":"assistant/chunk","seq":411,"time":1784629675370,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" would"}}} +{"type":"assistant/chunk","seq":412,"time":1784629675371,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" be"}}} +{"type":"assistant/chunk","seq":413,"time":1784629675371,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":414,"time":1784629675371,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} +{"type":"assistant/chunk","seq":415,"time":1784629675399,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} +{"type":"assistant/chunk","seq":416,"time":1784629675399,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":417,"time":1784629675399,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"+"}}} +{"type":"assistant/chunk","seq":418,"time":1784629675399,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} +{"type":"assistant/chunk","seq":419,"time":1784629675399,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_T"}}} +{"type":"assistant/chunk","seq":420,"time":1784629675427,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} +{"type":"assistant/chunk","seq":421,"time":1784629675428,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`.\n\n"}}} +{"type":"assistant/chunk","seq":422,"time":1784629675428,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"And"}}} +{"type":"assistant/chunk","seq":423,"time":1784629675456,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" console"}}} +{"type":"assistant/chunk","seq":424,"time":1784629675484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".log"}}} +{"type":"assistant/chunk","seq":425,"time":1784629675484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"(\""}}} +{"type":"assistant/chunk","seq":426,"time":1784629675515,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"capt"}}} +{"type":"assistant/chunk","seq":427,"time":1784629675515,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ured"}}} +{"type":"assistant/chunk","seq":428,"time":1784629675515,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":429,"time":1784629675515,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\")"}}} +{"type":"assistant/chunk","seq":430,"time":1784629675515,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" prints"}}} +{"type":"assistant/chunk","seq":431,"time":1784629675546,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":432,"time":1784629675579,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"capt"}}} +{"type":"assistant/chunk","seq":433,"time":1784629675579,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ured"}}} +{"type":"assistant/chunk","seq":434,"time":1784629675580,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":435,"time":1784629675580,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":436,"time":1784629675609,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":437,"time":1784629675609,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":438,"time":1784629675638,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" console"}}} +{"type":"assistant/chunk","seq":439,"time":1784629675667,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\n\n"}}} +{"type":"assistant/chunk","seq":440,"time":1784629675667,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":441,"time":1784629675667,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" response"}}} +{"type":"assistant/chunk","seq":442,"time":1784629675695,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" comes"}}} +{"type":"assistant/chunk","seq":443,"time":1784629675724,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" back"}}} +{"type":"assistant/chunk","seq":444,"time":1784629675725,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} +{"type":"assistant/chunk","seq":445,"time":1784629675756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":446,"time":1784629675756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":447,"time":1784629675786,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":448,"time":1784629675786,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" which"}}} +{"type":"assistant/chunk","seq":449,"time":1784629675814,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" includes"}}} +{"type":"assistant/chunk","seq":450,"time":1784629675814,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" stdout"}}} +{"type":"assistant/chunk","seq":451,"time":1784629675843,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":452,"time":1784629675873,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":453,"time":1784629675901,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" return"}}} +{"type":"assistant/chunk","seq":454,"time":1784629675901,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" value"}}} +{"type":"assistant/chunk","seq":455,"time":1784629675901,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":456,"time":1784629675932,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Since"}}} +{"type":"assistant/chunk","seq":457,"time":1784629675932,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":458,"time":1784629675932,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" return"}}} +{"type":"assistant/chunk","seq":459,"time":1784629675961,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" value"}}} +{"type":"assistant/chunk","seq":460,"time":1784629675991,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":461,"time":1784629675991,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} +{"type":"assistant/chunk","seq":462,"time":1784629676026,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" we"}}} +{"type":"assistant/chunk","seq":463,"time":1784629676050,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" want"}}} +{"type":"assistant/chunk","seq":464,"time":1784629676077,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":465,"time":1784629676077,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":466,"time":1784629676108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":467,"time":1784629676108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"reply"}}} +{"type":"assistant/chunk","seq":468,"time":1784629676136,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":469,"time":1784629676167,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":470,"time":1784629676168,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" joined"}}} +{"type":"assistant/chunk","seq":471,"time":1784629676168,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" string"}}} +{"type":"assistant/chunk","seq":472,"time":1784629676168,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" only"}}} +{"type":"assistant/chunk","seq":473,"time":1784629676198,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":474,"time":1784629676198,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":475,"time":1784629676198,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":476,"time":1784629676227,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" -"}}} +{"type":"assistant/chunk","seq":477,"time":1784629676227,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":478,"time":1784629676258,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" think"}}} +{"type":"assistant/chunk","seq":479,"time":1784629676258,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":480,"time":1784629676286,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" return"}}} +{"type":"assistant/chunk","seq":481,"time":1784629676287,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" value"}}} +{"type":"assistant/chunk","seq":482,"time":1784629676287,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}} +{"type":"assistant/chunk","seq":483,"time":1784629676315,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":484,"time":1784629676315,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":485,"time":1784629676344,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_code"}}} +{"type":"assistant/chunk","seq":486,"time":1784629676344,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" function"}}} +{"type":"assistant/chunk","seq":487,"time":1784629676344,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":488,"time":1784629676344,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} +{"type":"assistant/chunk","seq":489,"time":1784629676372,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" matters"}}} +{"type":"assistant/chunk","seq":490,"time":1784629676372,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":491,"time":1784629676400,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" But"}}} +{"type":"assistant/chunk","seq":492,"time":1784629676401,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" console"}}} +{"type":"assistant/chunk","seq":493,"time":1784629676430,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".log"}}} +{"type":"assistant/chunk","seq":494,"time":1784629676431,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":495,"time":1784629676459,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" also"}}} +{"type":"assistant/chunk","seq":496,"time":1784629676486,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" comes"}}} +{"type":"assistant/chunk","seq":497,"time":1784629676514,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" back"}}} +{"type":"assistant/chunk","seq":498,"time":1784629676514,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\n\n"}}} +{"type":"assistant/chunk","seq":499,"time":1784629676514,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} +{"type":"assistant/chunk","seq":500,"time":1784629676543,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":501,"time":1784629676543,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":502,"time":1784629676571,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} +{"type":"assistant/chunk","seq":503,"time":1784629676599,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":504,"time":1784629676600,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":505,"time":1784629676600,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" see"}}} +{"type":"assistant/chunk","seq":506,"time":1784629676630,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":507,"time":1784629676713,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":508,"time":1784629676713,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":509,"time":1784629676742,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":510,"time":1784629676742,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":511,"time":1784629676742,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"code"}}} +{"type":"assistant/chunk","seq":512,"time":1784629676742,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":513,"time":1784629676742,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":514,"time":1784629676770,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":515,"time":1784629676770,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"\\n"}}} +{"type":"assistant/chunk","seq":516,"time":1784629676770,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"const"}}} +{"type":"assistant/chunk","seq":517,"time":1784629676770,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":" r"}}} +{"type":"assistant/chunk","seq":518,"time":1784629676770,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"1"}}} +{"type":"assistant/chunk","seq":519,"time":1784629676770,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":" ="}}} +{"type":"assistant/chunk","seq":520,"time":1784629676799,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":" await"}}} +{"type":"assistant/chunk","seq":521,"time":1784629676799,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":" tools"}}} +{"type":"assistant/chunk","seq":522,"time":1784629676799,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":".b"}}} +{"type":"assistant/chunk","seq":523,"time":1784629676799,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"ash"}}} +{"type":"assistant/chunk","seq":524,"time":1784629676799,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"({"}}} +{"type":"assistant/chunk","seq":525,"time":1784629676827,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":" command"}}} +{"type":"assistant/chunk","seq":526,"time":1784629676828,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":527,"time":1784629676828,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":528,"time":1784629676828,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":529,"time":1784629676828,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":" CODE"}}} +{"type":"assistant/chunk","seq":530,"time":1784629676855,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"_"}}} +{"type":"assistant/chunk","seq":531,"time":1784629676856,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"ONE"}}} +{"type":"assistant/chunk","seq":532,"time":1784629676856,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"\\\","}}} +{"type":"assistant/chunk","seq":533,"time":1784629676856,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":" description"}}} +{"type":"assistant/chunk","seq":534,"time":1784629676856,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":535,"time":1784629676856,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":536,"time":1784629676883,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"E"}}} +{"type":"assistant/chunk","seq":537,"time":1784629676884,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"cho"}}} +{"type":"assistant/chunk","seq":538,"time":1784629676884,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":" CODE"}}} +{"type":"assistant/chunk","seq":539,"time":1784629676884,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"_"}}} +{"type":"assistant/chunk","seq":540,"time":1784629676884,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"ONE"}}} +{"type":"assistant/chunk","seq":541,"time":1784629676884,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":542,"time":1784629676912,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":" stdout"}}} +{"type":"assistant/chunk","seq":543,"time":1784629676912,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"\\\""}}} +{"type":"assistant/chunk","seq":544,"time":1784629676912,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":" });\\n"}}} +{"type":"assistant/chunk","seq":545,"time":1784629676912,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"const"}}} +{"type":"assistant/chunk","seq":546,"time":1784629676912,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":" r"}}} +{"type":"assistant/chunk","seq":547,"time":1784629676912,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"2"}}} +{"type":"assistant/chunk","seq":548,"time":1784629676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":" ="}}} +{"type":"assistant/chunk","seq":549,"time":1784629676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":" await"}}} +{"type":"assistant/chunk","seq":550,"time":1784629676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":" tools"}}} +{"type":"assistant/chunk","seq":551,"time":1784629676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":".b"}}} +{"type":"assistant/chunk","seq":552,"time":1784629676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"ash"}}} +{"type":"assistant/chunk","seq":553,"time":1784629676941,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"({"}}} +{"type":"assistant/chunk","seq":554,"time":1784629676972,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":" command"}}} +{"type":"assistant/chunk","seq":555,"time":1784629676972,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":556,"time":1784629676972,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":557,"time":1784629676972,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":558,"time":1784629676972,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":" CODE"}}} +{"type":"assistant/chunk","seq":559,"time":1784629676972,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"_T"}}} +{"type":"assistant/chunk","seq":560,"time":1784629677001,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"WO"}}} +{"type":"assistant/chunk","seq":561,"time":1784629677001,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"\\\","}}} +{"type":"assistant/chunk","seq":562,"time":1784629677001,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":" description"}}} +{"type":"assistant/chunk","seq":563,"time":1784629677001,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":564,"time":1784629677001,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":565,"time":1784629677002,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"E"}}} +{"type":"assistant/chunk","seq":566,"time":1784629677029,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"cho"}}} +{"type":"assistant/chunk","seq":567,"time":1784629677029,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":" CODE"}}} +{"type":"assistant/chunk","seq":568,"time":1784629677029,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"_T"}}} +{"type":"assistant/chunk","seq":569,"time":1784629677029,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"WO"}}} +{"type":"assistant/chunk","seq":570,"time":1784629677029,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":571,"time":1784629677029,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":" stdout"}}} +{"type":"assistant/chunk","seq":572,"time":1784629677058,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"\\\""}}} +{"type":"assistant/chunk","seq":573,"time":1784629677058,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":" });\\n"}}} +{"type":"assistant/chunk","seq":574,"time":1784629677058,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"console"}}} +{"type":"assistant/chunk","seq":575,"time":1784629677058,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":".log"}}} +{"type":"assistant/chunk","seq":576,"time":1784629677058,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"(\\\""}}} +{"type":"assistant/chunk","seq":577,"time":1784629677058,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"capt"}}} +{"type":"assistant/chunk","seq":578,"time":1784629677086,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"ured"}}} +{"type":"assistant/chunk","seq":579,"time":1784629677086,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":" output"}}} +{"type":"assistant/chunk","seq":580,"time":1784629677086,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"\\\");\\n"}}} +{"type":"assistant/chunk","seq":581,"time":1784629677086,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"return"}}} +{"type":"assistant/chunk","seq":582,"time":1784629677086,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":" r"}}} +{"type":"assistant/chunk","seq":583,"time":1784629677086,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"1"}}} +{"type":"assistant/chunk","seq":584,"time":1784629677114,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":".stdout"}}} +{"type":"assistant/chunk","seq":585,"time":1784629677115,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":".text"}}} +{"type":"assistant/chunk","seq":586,"time":1784629677143,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":".trim"}}} +{"type":"assistant/chunk","seq":587,"time":1784629677143,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"()"}}} +{"type":"assistant/chunk","seq":588,"time":1784629677143,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":" +"}}} +{"type":"assistant/chunk","seq":589,"time":1784629677143,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":" \\\"+"}}} +{"type":"assistant/chunk","seq":590,"time":1784629677172,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"\\\""}}} +{"type":"assistant/chunk","seq":591,"time":1784629677172,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":" +"}}} +{"type":"assistant/chunk","seq":592,"time":1784629677172,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":" r"}}} +{"type":"assistant/chunk","seq":593,"time":1784629677172,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"2"}}} +{"type":"assistant/chunk","seq":594,"time":1784629677172,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":".stdout"}}} +{"type":"assistant/chunk","seq":595,"time":1784629677172,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":".text"}}} +{"type":"assistant/chunk","seq":596,"time":1784629677200,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":".trim"}}} +{"type":"assistant/chunk","seq":597,"time":1784629677200,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"();\\n"}}} +{"type":"assistant/chunk","seq":598,"time":1784629677200,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":599,"time":1784629677231,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":600,"time":1784629677261,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Let me carefully read the instructions. I need to write ONE run_code program that:\n\n1. Calls bash with `echo CODE_ONE` - just the command \"echo CODE_ONE\"\n2. Calls bash with `echo CODE_TWO` - just the command \"echo CODE_TWO\"\n3. Inside that same program, console.log exactly `captured output`\n4. Return the two outputs joined with a plus sign\n\nLet me look at the bash tool signature carefully. The `command` parameter is the bash command to execute. So I'd pass `echo CODE_ONE` as the command.\n\nBut wait - I need to be careful about what the output looks like. The bash tool returns stdout/stderr objects. Let me check the output type:\n\n```ts\nbash: {\n kind: \"foreground\" | \"background\";\n // if foreground:\n exitCode: number | null;\n stdout: { text: string; truncated: boolean; spillPath?: string; };\n stderr: { text: string; ... };\n ...\n}\n```\n\nSo I need to extract `stdout.text` from each call.\n\nLet me also make sure about the `description` parameter - it's required and should be \"5-10 words\" describing the command.\n\nLet me write the code:\n\n```ts\nconst r1 = await tools.bash({ command: \"echo CODE_ONE\", description: \"Echo CODE_ONE to stdout\" });\nconst r2 = await tools.bash({ command: \"echo CODE_TWO\", description: \"Echo CODE_TWO to stdout\" });\nconsole.log(\"captured output\");\nreturn r1.stdout.text.trim() + \"+\" + r2.stdout.text.trim();\n```\n\nLet me think about the output format. `echo CODE_ONE` will output `CODE_ONE\\n`. So `r1.stdout.text` would be `\"CODE_ONE\\n\"` and after trim it's `\"CODE_ONE\"`.\n\nSo the joined string would be `CODE_ONE+CODE_TWO`.\n\nAnd console.log(\"captured output\") prints \"captured output\" to the console.\n\nThe response comes back as the tool result which includes stdout and the return value. Since the return value is what we want, and `reply with that joined string only and stop` - I think the return value of the run_code function is what matters. But console.log output also comes back.\n\nLet me just write it and see."}}}} +{"type":"assistant/chunk","seq":601,"time":1784629677261,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","arguments":"{\"code\": \"\\nconst r1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"Echo CODE_ONE to stdout\\\" });\\nconst r2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Echo CODE_TWO to stdout\\\" });\\nconsole.log(\\\"captured output\\\");\\nreturn r1.stdout.text.trim() + \\\"+\\\" + r2.stdout.text.trim();\\n\"}"}}}} +{"type":"assistant/chunk","seq":602,"time":1784629677261,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":6005,"outputTokens":626,"cacheReadTokens":0,"reasoningTokens":501}}}} +{"type":"assistant/chunk","seq":603,"time":1784629677261,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":604,"time":1784629677264,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"Let me carefully read the instructions. I need to write ONE run_code program that:\n\n1. Calls bash with `echo CODE_ONE` - just the command \"echo CODE_ONE\"\n2. Calls bash with `echo CODE_TWO` - just the command \"echo CODE_TWO\"\n3. Inside that same program, console.log exactly `captured output`\n4. Return the two outputs joined with a plus sign\n\nLet me look at the bash tool signature carefully. The `command` parameter is the bash command to execute. So I'd pass `echo CODE_ONE` as the command.\n\nBut wait - I need to be careful about what the output looks like. The bash tool returns stdout/stderr objects. Let me check the output type:\n\n```ts\nbash: {\n kind: \"foreground\" | \"background\";\n // if foreground:\n exitCode: number | null;\n stdout: { text: string; truncated: boolean; spillPath?: string; };\n stderr: { text: string; ... };\n ...\n}\n```\n\nSo I need to extract `stdout.text` from each call.\n\nLet me also make sure about the `description` parameter - it's required and should be \"5-10 words\" describing the command.\n\nLet me write the code:\n\n```ts\nconst r1 = await tools.bash({ command: \"echo CODE_ONE\", description: \"Echo CODE_ONE to stdout\" });\nconst r2 = await tools.bash({ command: \"echo CODE_TWO\", description: \"Echo CODE_TWO to stdout\" });\nconsole.log(\"captured output\");\nreturn r1.stdout.text.trim() + \"+\" + r2.stdout.text.trim();\n```\n\nLet me think about the output format. `echo CODE_ONE` will output `CODE_ONE\\n`. So `r1.stdout.text` would be `\"CODE_ONE\\n\"` and after trim it's `\"CODE_ONE\"`.\n\nSo the joined string would be `CODE_ONE+CODE_TWO`.\n\nAnd console.log(\"captured output\") prints \"captured output\" to the console.\n\nThe response comes back as the tool result which includes stdout and the return value. Since the return value is what we want, and `reply with that joined string only and stop` - I think the return value of the run_code function is what matters. But console.log output also comes back.\n\nLet me just write it and see."},{"type":"tool-call","id":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","arguments":"{\"code\": \"\\nconst r1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"Echo CODE_ONE to stdout\\\" });\\nconst r2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Echo CODE_TWO to stdout\\\" });\\nconsole.log(\\\"captured output\\\");\\nreturn r1.stdout.text.trim() + \\\"+\\\" + r2.stdout.text.trim();\\n\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":6005,"outputTokens":626,"cacheReadTokens":0,"reasoningTokens":501}},"sourceEventSeqs":[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,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428,429,430,431,432,433,434,435,436,437,438,439,440,441,442,443,444,445,446,447,448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499,500,501,502,503,504,505,506,507,508,509,510,511,512,513,514,515,516,517,518,519,520,521,522,523,524,525,526,527,528,529,530,531,532,533,534,535,536,537,538,539,540,541,542,543,544,545,546,547,548,549,550,551,552,553,554,555,556,557,558,559,560,561,562,563,564,565,566,567,568,569,570,571,572,573,574,575,576,577,578,579,580,581,582,583,584,585,586,587,588,589,590,591,592,593,594,595,596,597,598,599,600,601,602,603],"surfaceOp":"append"} +{"type":"tool/call","seq":605,"time":1784629677264,"data":{"turn":1,"step":1,"callId":"call_00_9r18203KAw8KCXThwJ7k3704","name":"run_code","arguments":"{\"code\": \"\\nconst r1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"Echo CODE_ONE to stdout\\\" });\\nconst r2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Echo CODE_TWO to stdout\\\" });\\nconsole.log(\\\"captured output\\\");\\nreturn r1.stdout.text.trim() + \\\"+\\\" + r2.stdout.text.trim();\\n\"}"}} +{"type":"tool/code-dispatch","seq":606,"time":1784629677359,"data":{"parentCallId":"call_00_9r18203KAw8KCXThwJ7k3704","subCallId":"call_00_9r18203KAw8KCXThwJ7k3704:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Echo CODE_ONE to stdout"},"isError":false,"resultSummary":"CODE_ONE\n"}} +{"type":"tool/code-dispatch","seq":607,"time":1784629677361,"data":{"parentCallId":"call_00_9r18203KAw8KCXThwJ7k3704","subCallId":"call_00_9r18203KAw8KCXThwJ7k3704:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Echo CODE_TWO to stdout"},"isError":false,"resultSummary":"CODE_TWO\n"}} +{"type":"tool/result","seq":608,"time":1784629677363,"data":{"turn":1,"step":1,"callId":"call_00_9r18203KAw8KCXThwJ7k3704","content":[{"type":"text","text":"captured output\nCODE_ONE+CODE_TWO"}],"isError":false},"sourceEventSeqs":[605],"surfaceOp":"append"} +{"type":"step/end","seq":609,"time":1784629677365,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":610,"time":1784629677366,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":611,"time":1784629677997,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":612,"time":1784629677997,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":613,"time":1784629678140,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":614,"time":1784629678165,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" shows"}}} +{"type":"assistant/chunk","seq":615,"time":1784629678193,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":\n"}}} +{"type":"assistant/chunk","seq":616,"time":1784629678193,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"-"}}} +{"type":"assistant/chunk","seq":617,"time":1784629678221,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":618,"time":1784629678221,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"capt"}}} +{"type":"assistant/chunk","seq":619,"time":1784629678222,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ured"}}} +{"type":"assistant/chunk","seq":620,"time":1784629678222,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":621,"time":1784629678249,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":622,"time":1784629678249,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} +{"type":"assistant/chunk","seq":623,"time":1784629678250,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"from"}}} +{"type":"assistant/chunk","seq":624,"time":1784629678278,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" console"}}} +{"type":"assistant/chunk","seq":625,"time":1784629678278,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".log"}}} +{"type":"assistant/chunk","seq":626,"time":1784629678278,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":")\n"}}} +{"type":"assistant/chunk","seq":627,"time":1784629678279,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"-"}}} +{"type":"assistant/chunk","seq":628,"time":1784629678279,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":629,"time":1784629678306,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} +{"type":"assistant/chunk","seq":630,"time":1784629678306,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} +{"type":"assistant/chunk","seq":631,"time":1784629678306,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":632,"time":1784629678306,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"+"}}} +{"type":"assistant/chunk","seq":633,"time":1784629678306,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} +{"type":"assistant/chunk","seq":634,"time":1784629678306,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_T"}}} +{"type":"assistant/chunk","seq":635,"time":1784629678334,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} +{"type":"assistant/chunk","seq":636,"time":1784629678334,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":637,"time":1784629678334,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} +{"type":"assistant/chunk","seq":638,"time":1784629678334,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"the"}}} +{"type":"assistant/chunk","seq":639,"time":1784629678361,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" return"}}} +{"type":"assistant/chunk","seq":640,"time":1784629678362,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" value"}}} +{"type":"assistant/chunk","seq":641,"time":1784629678362,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":")\n\n"}}} +{"type":"assistant/chunk","seq":642,"time":1784629678362,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":643,"time":1784629678362,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instructions"}}} +{"type":"assistant/chunk","seq":644,"time":1784629678390,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" say"}}} +{"type":"assistant/chunk","seq":645,"time":1784629678391,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":646,"time":1784629678391,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Reply"}}} +{"type":"assistant/chunk","seq":647,"time":1784629678417,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":648,"time":1784629678417,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":649,"time":1784629678417,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" joined"}}} +{"type":"assistant/chunk","seq":650,"time":1784629678418,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" string"}}} +{"type":"assistant/chunk","seq":651,"time":1784629678445,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" only"}}} +{"type":"assistant/chunk","seq":652,"time":1784629678446,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":653,"time":1784629678446,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":654,"time":1784629678473,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}} +{"type":"assistant/chunk","seq":655,"time":1784629678474,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" So"}}} +{"type":"assistant/chunk","seq":656,"time":1784629678474,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":657,"time":1784629678474,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} +{"type":"assistant/chunk","seq":658,"time":1784629678501,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":659,"time":1784629678501,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":660,"time":1784629678502,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":661,"time":1784629678529,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} +{"type":"assistant/chunk","seq":662,"time":1784629678529,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} +{"type":"assistant/chunk","seq":663,"time":1784629678529,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":664,"time":1784629678529,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"+"}}} +{"type":"assistant/chunk","seq":665,"time":1784629678529,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} +{"type":"assistant/chunk","seq":666,"time":1784629678530,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_T"}}} +{"type":"assistant/chunk","seq":667,"time":1784629678557,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} +{"type":"assistant/chunk","seq":668,"time":1784629678558,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":669,"time":1784629678558,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":670,"time":1784629678558,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"CODE"}}} +{"type":"assistant/chunk","seq":671,"time":1784629678558,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_"}}} +{"type":"assistant/chunk","seq":672,"time":1784629678558,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":673,"time":1784629678587,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"+"}}} +{"type":"assistant/chunk","seq":674,"time":1784629678587,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"CODE"}}} +{"type":"assistant/chunk","seq":675,"time":1784629678587,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_T"}}} +{"type":"assistant/chunk","seq":676,"time":1784629678587,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"WO"}}} +{"type":"assistant/chunk","seq":677,"time":1784629678587,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The output shows:\n- \"captured output\" (from console.log)\n- \"CODE_ONE+CODE_TWO\" (the return value)\n\nThe instructions say \"Reply with that joined string only and stop.\" So I should reply with \"CODE_ONE+CODE_TWO\"."}}}} +{"type":"assistant/chunk","seq":678,"time":1784629678587,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CODE_ONE+CODE_TWO"}}}} +{"type":"assistant/chunk","seq":679,"time":1784629678587,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":766,"outputTokens":65,"cacheReadTokens":5888,"reasoningTokens":57}}}} +{"type":"assistant/chunk","seq":680,"time":1784629678587,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":681,"time":1784629678588,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The output shows:\n- \"captured output\" (from console.log)\n- \"CODE_ONE+CODE_TWO\" (the return value)\n\nThe instructions say \"Reply with that joined string only and stop.\" So I should reply with \"CODE_ONE+CODE_TWO\"."},{"type":"text","text":"CODE_ONE+CODE_TWO"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":766,"outputTokens":65,"cacheReadTokens":5888,"reasoningTokens":57}},"sourceEventSeqs":[611,612,613,614,615,616,617,618,619,620,621,622,623,624,625,626,627,628,629,630,631,632,633,634,635,636,637,638,639,640,641,642,643,644,645,646,647,648,649,650,651,652,653,654,655,656,657,658,659,660,661,662,663,664,665,666,667,668,669,670,671,672,673,674,675,676,677,678,679,680],"surfaceOp":"append"} +{"type":"step/end","seq":682,"time":1784629678588,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":683,"time":1784629678588,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl index 5e366ec1a3..a33db80ea4 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl @@ -2,21 +2,83 @@ {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"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]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Using ONE run_code program: call","updatedAt":"{{updatedAt}}"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" carefully"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" read"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instructions"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" write"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ONE"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_code"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" program"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" calls"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"1"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Calls"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" twice"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" CODE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" -"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" just"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" CODE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"2"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Calls"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" CODE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_T"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"WO"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" -"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" just"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" CODE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_T"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"WO"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"3"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Inside"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" same"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" program"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" then"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returns"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" console"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".log"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"capt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ured"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"4"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Return"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" two"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" outputs"}}}} @@ -25,31 +87,481 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" plus"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sign"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" look"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" at"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" signature"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" carefully"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"command"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" parameter"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" execute"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" So"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'d"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" pass"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" CODE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" as"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"But"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wait"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" -"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" be"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" careful"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" about"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" what"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" looks"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" like"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returns"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stdout"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"/st"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"derr"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" objects"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" write"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_DRxnM6R1TThfDwcudW0f2050","title":"const out1 = await tools.bash({ command: \"echo CODE_ONE\", description: \"First echo\" });\nconst out2 = await tools.bash({ command: \"echo CODE_TWO\", description: \"Second echo\" });\nreturn out1.trim() + \"+\" + out2.trim();","kind":"execute","status":"in_progress","rawInput":"const out1 = await tools.bash({ command: \"echo CODE_ONE\", description: \"First echo\" });\nconst out2 = await tools.bash({ command: \"echo CODE_TWO\", description: \"Second echo\" });\nreturn out1.trim() + \"+\" + out2.trim();"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_DRxnM6R1TThfDwcudW0f2050","status":"completed","content":[{"type":"content","content":{"type":"text","text":"CODE_ONE+CODE_TWO"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" what"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" check"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" for"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" type"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"```"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ts"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"bash"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" {\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" "}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" kind"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"fore"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ground"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" |"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"background"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\";\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" "}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" //"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" if"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" foreground"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" "}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exit"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Code"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" number"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" |"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" null"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":";\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" "}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stdout"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" {"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" text"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" string"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":";"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" truncated"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" boolean"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":";"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" spill"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Path"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"?:"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" string"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":";"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" };\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" "}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" st"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"derr"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" {"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" text"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" string"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":";"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ..."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" };\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" "}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ...\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"}\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"```\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"So"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" extract"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"std"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"out"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".text"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" from"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" each"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" call"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" also"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" make"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sure"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" about"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"description"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" parameter"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" -"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'s"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" required"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" should"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" be"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"5"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"-"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"10"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" words"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" describing"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" write"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" code"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"```"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ts"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"const"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" r"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"1"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ="}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" await"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tools"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".b"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"({"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" CODE"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" description"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"E"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"cho"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" CODE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stdout"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" });\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"const"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" r"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"2"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ="}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" await"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tools"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".b"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"({"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" CODE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_T"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"WO"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" description"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"E"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"cho"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" CODE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_T"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"WO"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stdout"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" });\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"console"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".log"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"(\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"capt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ured"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\");\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"return"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" r"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"1"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".stdout"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".text"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".trim"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"()"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" +"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \"+"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" +"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" r"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"2"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".stdout"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".text"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".trim"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"();\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"```\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" think"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" about"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" format"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" CODE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" will"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"CODE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" So"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"r"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"1"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".stdout"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".text"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" would"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" be"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"CODE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" after"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" trim"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'s"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"CODE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`.\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"So"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" joined"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" string"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" would"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" be"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"CODE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"+"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"CODE"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_T"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"WO"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`.\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"And"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" console"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".log"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"(\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"capt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ured"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\")"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" prints"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"capt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ured"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" console"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" response"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" comes"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" back"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" as"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" which"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" includes"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stdout"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" return"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" value"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Since"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" return"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" value"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" what"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" we"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" want"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" joined"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" string"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" only"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" -"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" think"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" return"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" value"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" of"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_code"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" function"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" what"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" matters"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" But"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" console"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".log"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" also"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" comes"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" back"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" just"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" write"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" see"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_9r18203KAw8KCXThwJ7k3704","title":"\nconst r1 = await tools.bash({ command: \"echo CODE_ONE\", description: \"Echo CODE_ONE to stdout\" });\nconst r2 = await tools.bash({ command: \"echo CODE_TWO\", description: \"Echo CODE_TWO to stdout\" });\nconsole.log(\"captured output\");\nreturn r1.stdout.text.trim() + \"+\" + r2.stdout.text.trim();\n","kind":"execute","status":"in_progress","rawInput":"\nconst r1 = await tools.bash({ command: \"echo CODE_ONE\", description: \"Echo CODE_ONE to stdout\" });\nconst r2 = await tools.bash({ command: \"echo CODE_TWO\", description: \"Echo CODE_TWO to stdout\" });\nconsole.log(\"captured output\");\nreturn r1.stdout.text.trim() + \"+\" + r2.stdout.text.trim();\n"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_9r18203KAw8KCXThwJ7k3704","status":"completed","content":[{"type":"content","content":{"type":"text","text":"captured output\nCODE_ONE+CODE_TWO"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" shows"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"-"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"capt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ured"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ("}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"from"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" console"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".log"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":")\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"-"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"CODE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"+"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"CODE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_T"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"WO"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ("}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" return"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" value"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":")\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instructions"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" say"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" joined"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" string"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" only"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" So"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" should"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"CODE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"+"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"CODE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_T"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"WO"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CODE"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"_"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}} 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 8868412707..8744029272 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 @@ -28,19 +28,21 @@ Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop o Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program: -- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable. -- A FAILED tool call rejects with an `Error` carrying the tool's error text — `try/catch` it to handle and continue. +- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. +- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue. - Calls execute sequentially, even under `Promise.all`. - Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need. The available tools: ```ts -declare const tools: { +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } + +interface ToolArgsMap { /** Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. Send one or more questions, each with a stable id that will be echoed in the answer. */ - ask_user_question(args: { + ask_user_question: { /** Questions to ask the user before continuing. */ - questions: { + questions: ({ /** Stable id for this question; echoed in the answer. */ id: string; /** The specific question to ask the user. */ @@ -48,18 +50,18 @@ declare const tools: { /** Optional short heading for the question, such as "Confirm" or "Choose Mode". */ header?: string; /** Optional choices to show the user. If you recommend one, put it first and append "(Recommended)" to that label. */ - options?: { + options?: ({ /** Short user-facing option label. */ label: string; /** One sentence explaining the tradeoff or impact. */ description?: string; - }[]; + } & Record)[]; /** Whether the user may select more than one option. Defaults to false. */ multi_select?: boolean; - }[]; - }): Promise; + } & Record)[]; + } & Record; /** 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: { + bash: { /** The bash command to execute. */ command: string; /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: "ls" → "List files in current directory"; "git status" → "Show working tree status"; "npm install" → "Install package dependencies". */ @@ -74,16 +76,16 @@ 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; /** Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. */ - create_goal(args: { + create_goal: { /** 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; /** Edit an existing UTF-8 text file by replacing literal text. */ - edit(args: { + edit: { /** Path to edit, resolved by the filesystem backend. */ file_path: string; /** Literal text to replace. Must match exactly. */ @@ -96,83 +98,83 @@ 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; /** Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. Send the COMPLETE plan as markdown, starting with a # heading that names it. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again. */ - exit_plan_mode(args: { + exit_plan_mode: { /** The complete plan, as markdown, starting with a # heading that names it. */ plan: string; - }): Promise; + } & Record; /** 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: Record; /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ - ralph(args: { + ralph: { /** 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; /** Read a UTF-8 text file and return line-numbered content. */ - read(args: { + read: { /** Path to read, resolved by the filesystem backend. */ file_path: string; /** 1-based first line to return. Defaults to 1. */ offset?: number; /** Maximum number of lines to return. Defaults to 2000. */ limit?: number; - }): Promise; + } & Record; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ - skill(args: { + skill: { /** The exact skill name from the available skills list. */ name: string; - }): Promise; + } & Record; /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ - subagent(args: { + subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ 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; /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ - subagent_fork(args: { + subagent_fork: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */ 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; /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */ - task_kill(args: { + task_kill: { /** 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; /** List your background tasks (running and finished) with their ids, kinds, and statuses. */ - task_list(args: Record): Promise; + task_list: Record; /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */ - task_output(args: { + task_output: { /** Task id returned by the tool that started the background work. */ task_id: string; /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */ 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; /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */ - todo_write(args: { + todo_write: { /** The COMPLETE task list, replacing any previous list. */ todos: ({ /** What the task is — a short imperative line. */ content: string; /** pending (not started) | in_progress (now) | completed (done). */ status: "pending" | "in_progress" | "completed"; - })[]; - }): Promise; + } & Record)[]; + } & Record; /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ - update_goal(args: { + update_goal: { /** Exact id returned by get_goal. */ goal_id: string; /** Exact positive revision returned by get_goal. */ @@ -185,9 +187,9 @@ declare const tools: { max_goal_rounds?: number; /** Concrete blocking condition; required only with action blocked. */ blocked_reason?: string; - }): 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: { + } & Record; + /** 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/oneOf — no 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: { /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */ script: string; /** The workflow identity block (plain JSON — never code). */ @@ -199,7 +201,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. */ @@ -208,13 +210,13 @@ 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; /** Create or fully replace a UTF-8 text file. */ - write(args: { + write: { /** Path to write, resolved by the filesystem backend. */ file_path: string; /** Full UTF-8 text content to write. */ @@ -223,6 +225,213 @@ 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; +} + +interface ToolOutputMap { + ask_user_question: { + answers: { + id: string; + selected: string[]; + custom?: string; + }[]; + }; + bash: { + kind: "background"; + taskId: string; + } | { + kind: "foreground"; + exitCode: number | null; + signal: string | null; + timedOut: boolean; + aborted: boolean; + timeoutMs: number; + stdout: { + text: string; + truncated: boolean; + spillPath?: string; + }; + stderr: { + text: string; + truncated: boolean; + spillPath?: string; + }; + sandbox?: { + mode: string; + denied: boolean; + enforcement?: string; + runnerFailed?: boolean; + }; + }; + create_goal: { + goal: null; + } | { + goal: { + id: string; + revision: number; + objective: string; + phase: "active" | "paused" | "blocked" | "complete"; + roundsStarted: number; + maxGoalRounds: number; + blockedReason?: { + code: string; + message: string; + }; + }; + activation: "armed" | "disarmed"; + }; + edit: { + path: string; + before: string; + after: string; + }; + exit_plan_mode: { + approved: true; + }; + get_goal: { + goal: null; + } | { + goal: { + id: string; + revision: number; + objective: string; + phase: "active" | "paused" | "blocked" | "complete"; + roundsStarted: number; + maxGoalRounds: number; + blockedReason?: { + code: string; + message: string; + }; + }; + activation: "armed" | "disarmed"; + }; + ralph: { + runId: string; + agentsStarted: number; + result: JsonValue; + }; + read: { + path: string; + offset: number; + lines: { + number: number; + text: string; + }[]; + totalLines: number; + }; + skill: { + name: string; + provider: string; + resourceBase?: { + kind: "directory"; + path: string; + } | { + kind: "url"; + url: string; + } | { + kind: "opaque"; + description: string; + }; + content: string; + }; + subagent: { + kind: "background"; + taskId: string; + } | { + kind: "foreground"; + runId: string; + output: JsonValue[]; + }; + subagent_fork: { + kind: "background"; + taskId: string; + } | { + kind: "foreground"; + runId: string; + output: JsonValue[]; + }; + task_kill: { + outcome: "cancellation-requested" | "already-finished"; + task: { + id: string; + kind: string; + label: string; + status: "running" | "stopping" | "completed" | "killed" | "failed"; + detail?: string; + startedAt: number; + finishedAt?: number; + }; + }; + task_list: ({ + id: string; + kind: string; + label: string; + status: "running" | "stopping" | "completed" | "killed" | "failed"; + detail?: string; + startedAt: number; + finishedAt?: number; + })[]; + task_output: { + text: string; + task: { + id: string; + kind: string; + label: string; + status: "running" | "stopping" | "completed" | "killed" | "failed"; + detail?: string; + startedAt: number; + finishedAt?: number; + }; + }; + todo_write: { + todos: ({ + content: string; + status: "pending" | "in_progress" | "completed"; + })[]; + counts: { + pending: number; + inProgress: number; + completed: number; + }; + }; + update_goal: { + goal: null; + } | { + goal: { + id: string; + revision: number; + objective: string; + phase: "active" | "paused" | "blocked" | "complete"; + roundsStarted: number; + maxGoalRounds: number; + blockedReason?: { + code: string; + message: string; + }; + }; + activation: "armed" | "disarmed"; + }; + workflow: { + runId: string; + agentsStarted: number; + result: JsonValue; + }; + write: { + path: string; + operation: "create" | "update"; + before: string | null; + after: string; + }; +} + +type ToolName = keyof ToolOutputMap + +declare class ToolCallError extends Error { + readonly name: "ToolCallError"; + readonly toolName: ToolName; +} + +declare const tools: { + [K in ToolName]: (args: ToolArgsMap[K]) => Promise; } ``` diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl index 281970523c..88152d69cd 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl @@ -75,18 +75,18 @@ {"type":"assistant/chunk","seq":73,"time":1783921767068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"\\\""}}} {"type":"assistant/chunk","seq":74,"time":1783921767068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" });\\n"}}} {"type":"assistant/chunk","seq":75,"time":1783921767068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"return"}}} -{"type":"assistant/chunk","seq":76,"time":1783921767095,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" content"}}} +{"type":"assistant/chunk","seq":76,"time":1783921767095,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" content.lines.map(line => line.text).join(String.fromCharCode(10))"}}} {"type":"assistant/chunk","seq":77,"time":1783921767095,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":";"}}} {"type":"assistant/chunk","seq":78,"time":1783921767095,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"\""}}} {"type":"assistant/chunk","seq":79,"time":1783921767121,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"}"}}} {"type":"assistant/chunk","seq":80,"time":1783921767205,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to write a single run_code program that reads a file called nested/task.txt and then answer the handshake question based on what's in that file. Let me start by reading the file."}}}} -{"type":"assistant/chunk","seq":81,"time":1783921767205,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","arguments":"{\"code\": \"const content = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn content;\"}"}}}} +{"type":"assistant/chunk","seq":81,"time":1783921767205,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","arguments":"{\"code\": \"const content = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn content.lines.map(line => line.text).join(String.fromCharCode(10));\"}"}}}} {"type":"assistant/chunk","seq":82,"time":1783921767205,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3195,"outputTokens":106,"cacheReadTokens":0,"reasoningTokens":43}}}} {"type":"assistant/chunk","seq":83,"time":1783921767205,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":84,"time":1783921767208,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single run_code program that reads a file called nested/task.txt and then answer the handshake question based on what's in that file. Let me start by reading the file."},{"type":"tool-call","id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","arguments":"{\"code\": \"const content = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn content;\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3195,"outputTokens":106,"cacheReadTokens":0,"reasoningTokens":43}},"sourceEventSeqs":[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,76,77,78,79,80,81,82,83],"surfaceOp":"append"} -{"type":"tool/call","seq":85,"time":1783921767208,"data":{"turn":1,"step":1,"callId":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","arguments":"{\"code\": \"const content = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn content;\"}"}} +{"type":"assistant/message","seq":84,"time":1783921767208,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single run_code program that reads a file called nested/task.txt and then answer the handshake question based on what's in that file. Let me start by reading the file."},{"type":"tool-call","id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","arguments":"{\"code\": \"const content = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn content.lines.map(line => line.text).join(String.fromCharCode(10));\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3195,"outputTokens":106,"cacheReadTokens":0,"reasoningTokens":43}},"sourceEventSeqs":[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,76,77,78,79,80,81,82,83],"surfaceOp":"append"} +{"type":"tool/call","seq":85,"time":1783921767208,"data":{"turn":1,"step":1,"callId":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","arguments":"{\"code\": \"const content = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn content.lines.map(line => line.text).join(String.fromCharCode(10));\"}"}} {"type":"tool/code-dispatch","seq":86,"time":1783921767270,"data":{"parentCallId":"call_00_6APApmaKLRDlXKMdIcWL5139","subCallId":"call_00_6APApmaKLRDlXKMdIcWL5139:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"resultSummary":"./nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}} -{"type":"tool/result","seq":87,"time":1783921767271,"data":{"turn":1,"step":1,"callId":"call_00_6APApmaKLRDlXKMdIcWL5139","content":[{"type":"text","text":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-uorU26/nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[85],"surfaceOp":"append"} +{"type":"tool/result","seq":87,"time":1783921767271,"data":{"turn":1,"step":1,"callId":"call_00_6APApmaKLRDlXKMdIcWL5139","content":[{"type":"text","text":"Touch this file to discover the nested workspace instruction."}],"isError":false},"sourceEventSeqs":[85],"surfaceOp":"append"} {"type":"context/message","seq":88,"time":1783921767272,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]}},"surfaceOp":"append"} {"type":"step/end","seq":89,"time":1783921767272,"data":{"turn":1,"step":1}} {"type":"step/start","seq":90,"time":1783921767272,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl index 5bdacf3b5c..3d6cba1bf2 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl @@ -45,8 +45,8 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_6APApmaKLRDlXKMdIcWL5139","title":"const content = await tools.read({ file_path: \"nested/task.txt\" });\nreturn content;","kind":"execute","status":"in_progress","rawInput":"const content = await tools.read({ file_path: \"nested/task.txt\" });\nreturn content;"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_6APApmaKLRDlXKMdIcWL5139","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_6APApmaKLRDlXKMdIcWL5139","title":"const content = await tools.read({ file_path: \"nested/task.txt\" });\nreturn content.lines.map(line => line.text).join(String.fromCharCode(10));","kind":"execute","status":"in_progress","rawInput":"const content = await tools.read({ file_path: \"nested/task.txt\" });\nreturn content.lines.map(line => line.text).join(String.fromCharCode(10));"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_6APApmaKLRDlXKMdIcWL5139","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Touch this file to discover the nested workspace instruction."}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" nested"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"/t"}}}} 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 8868412707..8744029272 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 @@ -28,19 +28,21 @@ Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop o Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program: -- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable. -- A FAILED tool call rejects with an `Error` carrying the tool's error text — `try/catch` it to handle and continue. +- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. +- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue. - Calls execute sequentially, even under `Promise.all`. - Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need. The available tools: ```ts -declare const tools: { +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } + +interface ToolArgsMap { /** Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. Send one or more questions, each with a stable id that will be echoed in the answer. */ - ask_user_question(args: { + ask_user_question: { /** Questions to ask the user before continuing. */ - questions: { + questions: ({ /** Stable id for this question; echoed in the answer. */ id: string; /** The specific question to ask the user. */ @@ -48,18 +50,18 @@ declare const tools: { /** Optional short heading for the question, such as "Confirm" or "Choose Mode". */ header?: string; /** Optional choices to show the user. If you recommend one, put it first and append "(Recommended)" to that label. */ - options?: { + options?: ({ /** Short user-facing option label. */ label: string; /** One sentence explaining the tradeoff or impact. */ description?: string; - }[]; + } & Record)[]; /** Whether the user may select more than one option. Defaults to false. */ multi_select?: boolean; - }[]; - }): Promise; + } & Record)[]; + } & Record; /** 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: { + bash: { /** The bash command to execute. */ command: string; /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: "ls" → "List files in current directory"; "git status" → "Show working tree status"; "npm install" → "Install package dependencies". */ @@ -74,16 +76,16 @@ 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; /** Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. */ - create_goal(args: { + create_goal: { /** 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; /** Edit an existing UTF-8 text file by replacing literal text. */ - edit(args: { + edit: { /** Path to edit, resolved by the filesystem backend. */ file_path: string; /** Literal text to replace. Must match exactly. */ @@ -96,83 +98,83 @@ 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; /** Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. Send the COMPLETE plan as markdown, starting with a # heading that names it. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again. */ - exit_plan_mode(args: { + exit_plan_mode: { /** The complete plan, as markdown, starting with a # heading that names it. */ plan: string; - }): Promise; + } & Record; /** 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: Record; /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ - ralph(args: { + ralph: { /** 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; /** Read a UTF-8 text file and return line-numbered content. */ - read(args: { + read: { /** Path to read, resolved by the filesystem backend. */ file_path: string; /** 1-based first line to return. Defaults to 1. */ offset?: number; /** Maximum number of lines to return. Defaults to 2000. */ limit?: number; - }): Promise; + } & Record; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ - skill(args: { + skill: { /** The exact skill name from the available skills list. */ name: string; - }): Promise; + } & Record; /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ - subagent(args: { + subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ 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; /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ - subagent_fork(args: { + subagent_fork: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */ 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; /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */ - task_kill(args: { + task_kill: { /** 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; /** List your background tasks (running and finished) with their ids, kinds, and statuses. */ - task_list(args: Record): Promise; + task_list: Record; /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */ - task_output(args: { + task_output: { /** Task id returned by the tool that started the background work. */ task_id: string; /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */ 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; /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */ - todo_write(args: { + todo_write: { /** The COMPLETE task list, replacing any previous list. */ todos: ({ /** What the task is — a short imperative line. */ content: string; /** pending (not started) | in_progress (now) | completed (done). */ status: "pending" | "in_progress" | "completed"; - })[]; - }): Promise; + } & Record)[]; + } & Record; /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ - update_goal(args: { + update_goal: { /** Exact id returned by get_goal. */ goal_id: string; /** Exact positive revision returned by get_goal. */ @@ -185,9 +187,9 @@ declare const tools: { max_goal_rounds?: number; /** Concrete blocking condition; required only with action blocked. */ blocked_reason?: string; - }): 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: { + } & Record; + /** 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/oneOf — no 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: { /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */ script: string; /** The workflow identity block (plain JSON — never code). */ @@ -199,7 +201,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. */ @@ -208,13 +210,13 @@ 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; /** Create or fully replace a UTF-8 text file. */ - write(args: { + write: { /** Path to write, resolved by the filesystem backend. */ file_path: string; /** Full UTF-8 text content to write. */ @@ -223,6 +225,213 @@ 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; +} + +interface ToolOutputMap { + ask_user_question: { + answers: { + id: string; + selected: string[]; + custom?: string; + }[]; + }; + bash: { + kind: "background"; + taskId: string; + } | { + kind: "foreground"; + exitCode: number | null; + signal: string | null; + timedOut: boolean; + aborted: boolean; + timeoutMs: number; + stdout: { + text: string; + truncated: boolean; + spillPath?: string; + }; + stderr: { + text: string; + truncated: boolean; + spillPath?: string; + }; + sandbox?: { + mode: string; + denied: boolean; + enforcement?: string; + runnerFailed?: boolean; + }; + }; + create_goal: { + goal: null; + } | { + goal: { + id: string; + revision: number; + objective: string; + phase: "active" | "paused" | "blocked" | "complete"; + roundsStarted: number; + maxGoalRounds: number; + blockedReason?: { + code: string; + message: string; + }; + }; + activation: "armed" | "disarmed"; + }; + edit: { + path: string; + before: string; + after: string; + }; + exit_plan_mode: { + approved: true; + }; + get_goal: { + goal: null; + } | { + goal: { + id: string; + revision: number; + objective: string; + phase: "active" | "paused" | "blocked" | "complete"; + roundsStarted: number; + maxGoalRounds: number; + blockedReason?: { + code: string; + message: string; + }; + }; + activation: "armed" | "disarmed"; + }; + ralph: { + runId: string; + agentsStarted: number; + result: JsonValue; + }; + read: { + path: string; + offset: number; + lines: { + number: number; + text: string; + }[]; + totalLines: number; + }; + skill: { + name: string; + provider: string; + resourceBase?: { + kind: "directory"; + path: string; + } | { + kind: "url"; + url: string; + } | { + kind: "opaque"; + description: string; + }; + content: string; + }; + subagent: { + kind: "background"; + taskId: string; + } | { + kind: "foreground"; + runId: string; + output: JsonValue[]; + }; + subagent_fork: { + kind: "background"; + taskId: string; + } | { + kind: "foreground"; + runId: string; + output: JsonValue[]; + }; + task_kill: { + outcome: "cancellation-requested" | "already-finished"; + task: { + id: string; + kind: string; + label: string; + status: "running" | "stopping" | "completed" | "killed" | "failed"; + detail?: string; + startedAt: number; + finishedAt?: number; + }; + }; + task_list: ({ + id: string; + kind: string; + label: string; + status: "running" | "stopping" | "completed" | "killed" | "failed"; + detail?: string; + startedAt: number; + finishedAt?: number; + })[]; + task_output: { + text: string; + task: { + id: string; + kind: string; + label: string; + status: "running" | "stopping" | "completed" | "killed" | "failed"; + detail?: string; + startedAt: number; + finishedAt?: number; + }; + }; + todo_write: { + todos: ({ + content: string; + status: "pending" | "in_progress" | "completed"; + })[]; + counts: { + pending: number; + inProgress: number; + completed: number; + }; + }; + update_goal: { + goal: null; + } | { + goal: { + id: string; + revision: number; + objective: string; + phase: "active" | "paused" | "blocked" | "complete"; + roundsStarted: number; + maxGoalRounds: number; + blockedReason?: { + code: string; + message: string; + }; + }; + activation: "armed" | "disarmed"; + }; + workflow: { + runId: string; + agentsStarted: number; + result: JsonValue; + }; + write: { + path: string; + operation: "create" | "update"; + before: string | null; + after: string; + }; +} + +type ToolName = keyof ToolOutputMap + +declare class ToolCallError extends Error { + readonly name: "ToolCallError"; + readonly toolName: ToolName; +} + +declare const tools: { + [K in ToolName]: (args: ToolArgsMap[K]) => Promise; } ``` 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 bd13d3b146..7238728b48 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"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":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"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. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\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(cause?: AgentCancelCause): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\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 placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions extends Omit {\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 contexts?: HookContext[];\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 readonly 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":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"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. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\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(cause?: AgentCancelCause): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\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 placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions extends Omit {\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 contexts?: HookContext[];\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 readonly 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":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"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 6a78b05a65..56fc0733c4 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 @@ -3,7 +3,7 @@ {"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]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Inspect the exact tools service","updatedAt":"{{updatedAt}}"}}} {"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. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\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(cause?: AgentCancelCause): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\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 placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions extends Omit {\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 contexts?: HookContext[];\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 readonly 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. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\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(cause?: AgentCancelCause): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\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 placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions extends Omit {\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 contexts?: HookContext[];\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 readonly 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. Async gates must observe\n * `exec.signal`; the registry rechecks cancellation after they settle but\n * never abandons their promise.\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/fs-escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl index 424aeb1aa3..918b3ab4d5 100644 --- a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl @@ -92,7 +92,7 @@ {"type":"tool/call","seq":90,"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":91,"time":1784045703782,"data":{"id":"d7250dff-fc08-4163-9b51-f43f08d6f36f","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} {"type":"approval/decided","seq":92,"time":1784045703786,"data":{"id":"d7250dff-fc08-4163-9b51-f43f08d6f36f","outcome":"allowed-once"}} -{"type":"tool/result","seq":93,"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":[90],"surfaceOp":"append"} +{"type":"tool/result","seq":93,"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":[90],"surfaceOp":"append"} {"type":"step/end","seq":94,"time":1784045703798,"data":{"turn":1,"step":1}} {"type":"step/start","seq":95,"time":1784045703799,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":96,"time":1784045704512,"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 fe2c0b5b6b..dbaaf8a8b6 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl @@ -63,7 +63,7 @@ {"type":"assistant/chunk","seq":61,"time":1783352079886,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":62,"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":[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],"surfaceOp":"append"} {"type":"tool/call","seq":63,"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":64,"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":[63],"surfaceOp":"append"} +{"type":"tool/result","seq":64,"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":[63],"surfaceOp":"append"} {"type":"step/end","seq":65,"time":1783352079898,"data":{"turn":1,"step":1}} {"type":"step/start","seq":66,"time":1783352079899,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":67,"time":1783352080825,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json index 9839668511..b42a434388 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json @@ -11,6 +11,7 @@ "description": "Questions to ask the user before continuing.", "items": { "type": "object", + "additionalProperties": true, "properties": { "id": { "type": "string", @@ -29,6 +30,7 @@ "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", @@ -402,6 +404,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -476,7 +479,7 @@ }, { "name": "workflow", - "description": "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.\n\nThe 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.\n\nScript-body hooks:\n- `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.\n- `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.\n- `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`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: 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.", + "description": "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.\n\nThe 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.\n\nScript-body hooks:\n- `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/oneOf — no 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.\n- `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.\n- `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`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: 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.", "parameters": { "type": "object", "properties": { @@ -487,6 +490,7 @@ "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -505,6 +509,7 @@ "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -536,7 +541,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/model-switching/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json index 85acdd1ee1..ef40784fa5 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 @@ -11,6 +11,7 @@ "description": "Questions to ask the user before continuing.", "items": { "type": "object", + "additionalProperties": true, "properties": { "id": { "type": "string", @@ -29,6 +30,7 @@ "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", @@ -365,6 +367,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -439,7 +442,7 @@ }, { "name": "workflow", - "description": "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.\n\nThe 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.\n\nScript-body hooks:\n- `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.\n- `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.\n- `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`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: 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.", + "description": "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.\n\nThe 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.\n\nScript-body hooks:\n- `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/oneOf — no 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.\n- `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.\n- `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`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: 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.", "parameters": { "type": "object", "properties": { @@ -450,6 +453,7 @@ "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -468,6 +472,7 @@ "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -499,7 +504,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": [ @@ -555,6 +561,7 @@ "description": "Questions to ask the user before continuing.", "items": { "type": "object", + "additionalProperties": true, "properties": { "id": { "type": "string", @@ -573,6 +580,7 @@ "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", @@ -909,6 +917,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -983,7 +992,7 @@ }, { "name": "workflow", - "description": "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.\n\nThe 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.\n\nScript-body hooks:\n- `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.\n- `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.\n- `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`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: 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.", + "description": "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.\n\nThe 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.\n\nScript-body hooks:\n- `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/oneOf — no 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.\n- `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.\n- `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`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: 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.", "parameters": { "type": "object", "properties": { @@ -994,6 +1003,7 @@ "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -1012,6 +1022,7 @@ "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -1043,7 +1054,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 85acdd1ee1..ef40784fa5 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 @@ -11,6 +11,7 @@ "description": "Questions to ask the user before continuing.", "items": { "type": "object", + "additionalProperties": true, "properties": { "id": { "type": "string", @@ -29,6 +30,7 @@ "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", @@ -365,6 +367,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -439,7 +442,7 @@ }, { "name": "workflow", - "description": "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.\n\nThe 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.\n\nScript-body hooks:\n- `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.\n- `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.\n- `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`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: 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.", + "description": "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.\n\nThe 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.\n\nScript-body hooks:\n- `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/oneOf — no 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.\n- `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.\n- `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`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: 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.", "parameters": { "type": "object", "properties": { @@ -450,6 +453,7 @@ "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -468,6 +472,7 @@ "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -499,7 +504,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": [ @@ -555,6 +561,7 @@ "description": "Questions to ask the user before continuing.", "items": { "type": "object", + "additionalProperties": true, "properties": { "id": { "type": "string", @@ -573,6 +580,7 @@ "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", @@ -909,6 +917,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -983,7 +992,7 @@ }, { "name": "workflow", - "description": "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.\n\nThe 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.\n\nScript-body hooks:\n- `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.\n- `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.\n- `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`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: 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.", + "description": "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.\n\nThe 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.\n\nScript-body hooks:\n- `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/oneOf — no 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.\n- `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.\n- `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`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: 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.", "parameters": { "type": "object", "properties": { @@ -994,6 +1003,7 @@ "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -1012,6 +1022,7 @@ "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -1043,7 +1054,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/plan-mode/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/plan-mode/tool-schemas.expected.json index 85acdd1ee1..ef40784fa5 100644 --- a/examples/acp-agent/tests/snapshots/plan-mode/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/plan-mode/tool-schemas.expected.json @@ -11,6 +11,7 @@ "description": "Questions to ask the user before continuing.", "items": { "type": "object", + "additionalProperties": true, "properties": { "id": { "type": "string", @@ -29,6 +30,7 @@ "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", @@ -365,6 +367,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -439,7 +442,7 @@ }, { "name": "workflow", - "description": "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.\n\nThe 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.\n\nScript-body hooks:\n- `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.\n- `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.\n- `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`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: 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.", + "description": "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.\n\nThe 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.\n\nScript-body hooks:\n- `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/oneOf — no 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.\n- `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.\n- `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`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: 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.", "parameters": { "type": "object", "properties": { @@ -450,6 +453,7 @@ "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -468,6 +472,7 @@ "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -499,7 +504,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": [ @@ -555,6 +561,7 @@ "description": "Questions to ask the user before continuing.", "items": { "type": "object", + "additionalProperties": true, "properties": { "id": { "type": "string", @@ -573,6 +580,7 @@ "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", @@ -909,6 +917,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -983,7 +992,7 @@ }, { "name": "workflow", - "description": "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.\n\nThe 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.\n\nScript-body hooks:\n- `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.\n- `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.\n- `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`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: 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.", + "description": "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.\n\nThe 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.\n\nScript-body hooks:\n- `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/oneOf — no 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.\n- `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.\n- `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`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: 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.", "parameters": { "type": "object", "properties": { @@ -994,6 +1003,7 @@ "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -1012,6 +1022,7 @@ "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -1043,7 +1054,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/pty-tools/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json index d466996ed1..529b1419da 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json @@ -11,6 +11,7 @@ "description": "Questions to ask the user before continuing.", "items": { "type": "object", + "additionalProperties": true, "properties": { "id": { "type": "string", @@ -29,6 +30,7 @@ "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", @@ -494,6 +496,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -568,7 +571,7 @@ }, { "name": "workflow", - "description": "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.\n\nThe 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.\n\nScript-body hooks:\n- `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.\n- `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.\n- `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`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: 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.", + "description": "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.\n\nThe 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.\n\nScript-body hooks:\n- `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/oneOf — no 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.\n- `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.\n- `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`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: 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.", "parameters": { "type": "object", "properties": { @@ -579,6 +582,7 @@ "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -597,6 +601,7 @@ "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -628,7 +633,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/session-sandbox-root/session.jsonl b/examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl index 1abfd3ba7d..e92adbafb9 100644 --- a/examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl +++ b/examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl @@ -14,7 +14,7 @@ {"type":"assistant/chunk","seq":12,"time":1784567324143,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":13,"time":1784567324144,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_session_root","name":"write","arguments":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","seq":14,"time":1784567324145,"data":{"turn":1,"step":1,"callId":"call_session_root","name":"write","arguments":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}} -{"type":"tool/result","seq":15,"time":1784567324155,"data":{"turn":1,"step":1,"callId":"call_session_root","content":[{"type":"text","text":"/Users/cty/acp-snap-cwd-MABAjO/session-root.txt\nfile\n\nCreated file\n"}],"isError":false},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","seq":15,"time":1784567324155,"data":{"turn":1,"step":1,"callId":"call_session_root","content":[{"type":"text","text":"/Users/cty/acp-snap-cwd-MABAjO/session-root.txt\nfile\n\nCreated file\n"}],"isError":false,"meta":{"diffs":[]}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","seq":16,"time":1784567324157,"data":{"turn":1,"step":1}} {"type":"step/start","seq":17,"time":1784567324157,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":18,"time":1784567324157,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} 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 61d317e2f8..b01e7683d1 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 @@ -11,6 +11,7 @@ "description": "Questions to ask the user before continuing.", "items": { "type": "object", + "additionalProperties": true, "properties": { "id": { "type": "string", @@ -29,6 +30,7 @@ "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", @@ -365,6 +367,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -439,7 +442,7 @@ }, { "name": "workflow", - "description": "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.\n\nThe 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.\n\nScript-body hooks:\n- `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.\n- `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.\n- `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`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: 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.", + "description": "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.\n\nThe 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.\n\nScript-body hooks:\n- `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/oneOf — no 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.\n- `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.\n- `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`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: 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.", "parameters": { "type": "object", "properties": { @@ -450,6 +453,7 @@ "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -468,6 +472,7 @@ "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -499,7 +504,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 61d317e2f8..b01e7683d1 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 @@ -11,6 +11,7 @@ "description": "Questions to ask the user before continuing.", "items": { "type": "object", + "additionalProperties": true, "properties": { "id": { "type": "string", @@ -29,6 +30,7 @@ "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", @@ -365,6 +367,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -439,7 +442,7 @@ }, { "name": "workflow", - "description": "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.\n\nThe 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.\n\nScript-body hooks:\n- `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.\n- `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.\n- `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`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: 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.", + "description": "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.\n\nThe 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.\n\nScript-body hooks:\n- `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/oneOf — no 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.\n- `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.\n- `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`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: 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.", "parameters": { "type": "object", "properties": { @@ -450,6 +453,7 @@ "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -468,6 +472,7 @@ "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -499,7 +504,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 61d317e2f8..b01e7683d1 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 @@ -11,6 +11,7 @@ "description": "Questions to ask the user before continuing.", "items": { "type": "object", + "additionalProperties": true, "properties": { "id": { "type": "string", @@ -29,6 +30,7 @@ "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", @@ -365,6 +367,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -439,7 +442,7 @@ }, { "name": "workflow", - "description": "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.\n\nThe 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.\n\nScript-body hooks:\n- `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.\n- `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.\n- `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`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: 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.", + "description": "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.\n\nThe 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.\n\nScript-body hooks:\n- `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/oneOf — no 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.\n- `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.\n- `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`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: 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.", "parameters": { "type": "object", "properties": { @@ -450,6 +453,7 @@ "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -468,6 +472,7 @@ "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -499,7 +504,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/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts index c7f5d48562..071d91d55b 100644 --- a/examples/headless-agent/tests/code-mode.e2e.ts +++ b/examples/headless-agent/tests/code-mode.e2e.ts @@ -3,11 +3,12 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LlmService from '@deepseek-ai/dsh-llm' +import LlmService, { CallId, HarnessError } 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, { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools' +import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -18,6 +19,9 @@ import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import * as WorkspaceContext from '@deepseek-ai/dsh-workspace-context' +import TaskService from '@deepseek-ai/dsh-tasks' +import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' +import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' /** * With-key Code Mode proof: a real model receives only `run_code`, composes two @@ -73,6 +77,223 @@ async function workspaceCodeModeHarness(): Promise { return harness } +let keylessCall = 0 +const testToolSignal = new AbortController().signal + +/** Execute one outer Code Mode call through the real registry and worker. */ +function runCode(harness: Context, code: string, signal: AbortSignal = testToolSignal): Promise { + return harness.tools.execute({ + callId: CallId(`keyless-code-${++keylessCall}`), + name: RUN_CODE_NAME, + arguments: { code }, + signal, + }) +} + +/** Read the optional completion from a successful canonical `run_code` value. */ +function completion(result: ToolExecutionResult): unknown { + if (result.isError) { + throw new Error(result.content.filter(block => block.type === 'text').map(block => block.text).join('\n')) + } + const value = result.value + if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('invalid run_code result') + return value.result +} + +/** Keyless real-worker harness for direct typed-binding acceptance tests. */ +async function typedCodeModeHarness(): Promise { + const harness = new Context() + await harness.plugin(SystemPrompt) + await harness.plugin(ToolRegistry, { mode: 'code' }) + await harness.plugin(WorkerCodeRuntime, {}) + return harness +} + +/** Keyless real-worker harness with the task-owned bash lifecycle. */ +async function backgroundCodeModeHarness(cwd: string): Promise { + const harness = await typedCodeModeHarness() + await harness.plugin(TaskService) + await harness.plugin(ToolTasks, {}) + await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) + await harness.plugin(ToolBash) + return harness +} + +describe('Code Mode typed values: keyless real-worker contracts', () => { + it('crosses a large intermediate value intact and exposes only typed tool failure fields', async () => { + ctx = await typedCodeModeHarness() + ctx.tools.register(defineTool({ + name: 'large_value', + description: 'Return a large canonical string.', + parameters: {}, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, + execute: () => Promise.resolve('x'.repeat(100_000)), + })) + ctx.tools.register(defineTool({ + name: 'always_fail', + description: 'Fail for ToolCallError coverage.', + parameters: {}, + output: { schema: { type: 'null' }, render: () => [] }, + execute: () => Promise.reject(new HarnessError('expected failure', 'EXPECTED_INTERNAL_CODE')), + })) + + const value = completion(await runCode(ctx, ` + const large = await tools.large_value({}); + let failure; + try { + await tools.always_fail({}); + } catch (error) { + failure = { + typed: error instanceof ToolCallError, + name: error.name, + toolName: error.toolName, + message: error.message, + exposesCode: 'code' in error, + exposesContent: 'content' in error, + exposesInfo: 'info' in error, + }; + } + return { length: large.length, failure }; + `)) + + expect(value).toEqual({ + length: 100_000, + failure: { + typed: true, + name: 'ToolCallError', + toolName: 'always_fail', + message: 'expected failure', + exposesCode: false, + exposesContent: false, + exposesInfo: false, + }, + }) + }) + + it('returns a background task id, settles the outer run, and polls that id to completion', async () => { + workdir = await mkdtemp(join(tmpdir(), 'dsh-code-mode-background-')) + ctx = await backgroundCodeModeHarness(workdir) + + const taskId = completion(await runCode(ctx, ` + const started = await tools.bash({ + command: "sleep 0.2; printf 'background-complete\\n'", + description: 'Run completion marker in background', + run_in_background: true, + }); + return started.taskId; + `)) + expect(taskId).toBe('bash-1') + + const polled = completion(await runCode(ctx, ` + return await tools.task_output({ task_id: ${JSON.stringify(taskId)}, wait: true, timeout_ms: 5000 }); + `)) + if (typeof polled !== 'object' || polled === null || Array.isArray(polled)) throw new Error('invalid task_output completion') + const taskOutput = polled as Record + expect(taskOutput.text).toContain('background-complete') + expect(taskOutput.task).toMatchObject({ id: taskId, kind: 'bash', status: 'completed' }) + }, 15_000) + + it('pre-abort spawns nothing; post-publication abort leaves task_kill as the cancellation owner', async () => { + workdir = await mkdtemp(join(tmpdir(), 'dsh-code-mode-task-cancel-')) + ctx = await backgroundCodeModeHarness(workdir) + + const pre = new AbortController() + pre.abort('pre-aborted') + const preResult = await runCode(ctx, ` + return await tools.bash({ command: 'sleep 10', description: 'Must never start', run_in_background: true }); + `, pre.signal) + expect(preResult.isError).toBe(true) + expect(ctx.tasks.list()).toEqual([]) + + const afterPublication = new AbortController() + const running = runCode(ctx, ` + const started = await tools.bash({ command: 'sleep 10', description: 'Wait for explicit task kill', run_in_background: true }); + console.log(started.taskId); + await new Promise(() => {}); + `, afterPublication.signal) + for (let attempt = 0; attempt < 100 && ctx.tasks.list().length === 0; attempt++) { + await new Promise(resolve => setTimeout(resolve, 10)) + } + const task = ctx.tasks.list()[0] + expect(task).toMatchObject({ id: 'bash-1', status: 'running' }) + afterPublication.abort('outer-call-cancelled') + expect((await running).isError).toBe(true) + expect(ctx.tasks.list()[0]).toMatchObject({ id: task!.id, status: 'running' }) + + const killed = completion(await runCode(ctx, ` + return await tools.task_kill({ task_id: ${JSON.stringify(task!.id)}, reason: 'test owns cancellation' }); + `)) + expect(killed).toMatchObject({ outcome: 'cancellation-requested', task: { id: task!.id } }) + const settled = completion(await runCode(ctx, ` + return await tools.task_output({ task_id: ${JSON.stringify(task!.id)}, wait: true, timeout_ms: 5000 }); + `)) + expect(settled).toMatchObject({ task: { id: task!.id, status: 'killed' } }) + }, 15_000) + + it('keeps foreground bash coupled to the outer signal', async () => { + workdir = await mkdtemp(join(tmpdir(), 'dsh-code-mode-foreground-cancel-')) + ctx = await backgroundCodeModeHarness(workdir) + const controller = new AbortController() + const startedAt = Date.now() + const pending = runCode(ctx, ` + return await tools.bash({ command: 'sleep 10', description: 'Run cancellable foreground command' }); + `, controller.signal) + setTimeout(() => { controller.abort('stop-foreground') }, 200) + const result = await pending + expect(result.isError).toBe(true) + expect(Date.now() - startedAt).toBeLessThan(5_000) + expect(ctx.tasks.list()).toEqual([]) + }, 15_000) + + it('uses cordis_mount DTO ids directly for active and pending mounts, then confirms removal', async () => { + ctx = await typedCodeModeHarness() + await ctx.plugin(ToolCordis) + + const value = completion(await runCode(ctx, ` + const active = await tools.cordis_mount({ + code: "return { name: 'active-code-mode-plugin', apply(ctx) {} }", + }); + const pending = await tools.cordis_mount({ + code: "return { name: 'pending-code-mode-plugin', inject: ['missing-code-mode-service'], apply(ctx) {} }", + }); + const before = await tools.cordis_inspect({ what: 'dynamic' }); + const unmounted = await tools.cordis_unmount({ id: active.id }); + const after = await tools.cordis_inspect({ what: 'dynamic' }); + await tools.cordis_unmount({ id: pending.id }); + return { + active, + pending, + unmounted, + beforeContainsId: before.includes(active.id), + afterContainsId: after.includes(active.id), + }; + `)) + + expect(value).toEqual({ + active: { + id: 'dyn-1', + pluginName: 'active-code-mode-plugin', + state: 'active', + provides: [], + waitingFor: [], + }, + pending: { + id: 'dyn-2', + pluginName: 'pending-code-mode-plugin', + state: 'pending', + provides: [], + waitingFor: ['missing-code-mode-service'], + }, + unmounted: { id: 'dyn-1', pluginName: 'active-code-mode-plugin' }, + beforeContainsId: true, + afterContainsId: false, + }) + }) +}) + function waitForIdle(harness: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = harness.on('agent/status', (subject, status) => { diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 76c0f56cbf..045b9bb736 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884563,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884564,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable.\n- A FAILED tool call rejects with an `Error` carrying the tool's error text — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ndeclare const tools: {\n /** 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`. */\n bash(args: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n }): Promise;\n /** 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. */\n cordis_inspect(args: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n }): Promise;\n /** 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. */\n cordis_mount(args: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n }): Promise;\n /** 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). */\n cordis_unmount(args: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n }): Promise;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit(args: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n }): Promise;\n /** 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. */\n ralph(args: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n }): Promise;\n /** Read a UTF-8 text file and return line-numbered content. */\n read(args: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n }): Promise;\n /** 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. */\n skill(args: {\n /** The exact skill name from the available skills list. */\n name: string;\n }): Promise;\n /** 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`. */\n subagent(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n }): Promise;\n /** 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`. */\n subagent_fork(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n }): Promise;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill(args: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n }): Promise;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list(args: Record): Promise;\n /** 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. */\n task_output(args: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n }): Promise;\n /** 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). */\n todo_write(args: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n }): Promise;\n /** 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. */\n workflow(args: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: {\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n }[];\n };\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n }): Promise;\n /** Create or fully replace a UTF-8 text file. */\n write(args: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n }): Promise;\n}\n```","tools":[{"name":"bash","description":"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`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"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.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"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.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"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).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"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.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"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 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"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.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"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`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"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`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"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.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"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).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"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.\n\nThe 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.\n\nScript-body hooks:\n- `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.\n- `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.\n- `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`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: 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.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"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\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** 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`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** 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. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** 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. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** 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). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** 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. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** 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. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** 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`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** 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. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** 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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n } & Record)[];\n } & Record;\n /** 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/oneOf — no 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. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"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`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"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.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"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 } }, 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":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"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).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"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.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"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 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"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.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"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`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"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`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"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.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"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).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":true,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"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.\n\nThe 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.\n\nScript-body hooks:\n- `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/oneOf — no 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.\n- `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.\n- `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`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: 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.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"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\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1783950001005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index d094d80951..8193973bee 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884700,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884700,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable.\n- A FAILED tool call rejects with an `Error` carrying the tool's error text — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ndeclare const tools: {\n /** 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`. */\n bash(args: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n }): Promise;\n /** 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. */\n cordis_inspect(args: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n }): Promise;\n /** 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. */\n cordis_mount(args: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n }): Promise;\n /** 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). */\n cordis_unmount(args: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n }): Promise;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit(args: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n }): Promise;\n /** 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. */\n ralph(args: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n }): Promise;\n /** Read a UTF-8 text file and return line-numbered content. */\n read(args: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n }): Promise;\n /** 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. */\n skill(args: {\n /** The exact skill name from the available skills list. */\n name: string;\n }): Promise;\n /** 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`. */\n subagent(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n }): Promise;\n /** 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`. */\n subagent_fork(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n }): Promise;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill(args: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n }): Promise;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list(args: Record): Promise;\n /** 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. */\n task_output(args: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n }): Promise;\n /** 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). */\n todo_write(args: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n }): Promise;\n /** 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. */\n workflow(args: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: {\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n }[];\n };\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n }): Promise;\n /** Create or fully replace a UTF-8 text file. */\n write(args: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n }): Promise;\n}\n```","tools":[{"name":"bash","description":"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`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"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.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"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.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"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).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"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.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"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 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"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.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"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`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"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`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"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.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"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).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"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.\n\nThe 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.\n\nScript-body hooks:\n- `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.\n- `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.\n- `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`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: 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.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"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\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** 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`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** 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. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** 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. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** 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). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** 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. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** 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. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** 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`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** 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. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** 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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n } & Record)[];\n } & Record;\n /** 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/oneOf — no 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. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"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`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"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.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"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 } }, 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":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"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).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"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.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"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 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"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.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"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`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"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`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"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.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"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).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":true,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"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.\n\nThe 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.\n\nScript-body hooks:\n- `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/oneOf — no 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.\n- `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.\n- `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`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: 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.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"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\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1783950002005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl index 309158c310..7f913ae905 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884479,"data":{"title":"Run this advanced flow exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable.\n- A FAILED tool call rejects with an `Error` carrying the tool's error text — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ndeclare const tools: {\n /** 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`. */\n bash(args: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n }): Promise;\n /** 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. */\n cordis_inspect(args: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n }): Promise;\n /** 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. */\n cordis_mount(args: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n }): Promise;\n /** 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). */\n cordis_unmount(args: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n }): Promise;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit(args: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n }): Promise;\n /** 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. */\n ralph(args: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n }): Promise;\n /** Read a UTF-8 text file and return line-numbered content. */\n read(args: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n }): Promise;\n /** 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. */\n skill(args: {\n /** The exact skill name from the available skills list. */\n name: string;\n }): Promise;\n /** 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`. */\n subagent(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n }): Promise;\n /** 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`. */\n subagent_fork(args: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n }): Promise;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill(args: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n }): Promise;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list(args: Record): Promise;\n /** 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. */\n task_output(args: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n }): Promise;\n /** 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). */\n todo_write(args: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n }): Promise;\n /** 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. */\n workflow(args: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: {\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n }[];\n };\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n }): Promise;\n /** Create or fully replace a UTF-8 text file. */\n write(args: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n }): Promise;\n}\n```","tools":[{"name":"bash","description":"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`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"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.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"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.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"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).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"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.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"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 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"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.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"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`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"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`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"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.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"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).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"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.\n\nThe 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.\n\nScript-body hooks:\n- `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.\n- `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.\n- `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`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: 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.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"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\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** 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`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** 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. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** 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. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** 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). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** 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. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** 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. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** 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`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** 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. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** 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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n } & Record)[];\n } & Record;\n /** 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/oneOf — no 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. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"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`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"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.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"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 } }, 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":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"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).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"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.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"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 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"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.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"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`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"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`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"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.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"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).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":true,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"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.\n\nThe 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.\n\nScript-body hooks:\n- `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/oneOf — no 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.\n- `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.\n- `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`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: 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.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"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\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":6,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} {"type":"assistant/chunk","seq":7,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} @@ -22,7 +22,7 @@ {"type":"assistant/message","seq":20,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} {"type":"tool/call","seq":21,"time":1783957884490,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}} {"type":"tool/code-dispatch","seq":22,"time":1783957884560,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"},"isError":false,"resultSummary":"## dynamic\n- dyn-1: snapshot-marker [active]"}} -{"type":"tool/result","seq":23,"time":1783957884561,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[21],"surfaceOp":"append"} +{"type":"tool/result","seq":23,"time":1783957884561,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"} {"type":"step/end","seq":24,"time":1783957884561,"data":{"turn":1,"step":2}} {"type":"step/start","seq":25,"time":1783957884562,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":26,"time":1783950000026,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl index 0f59fa79d9..0480fdf2ac 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl @@ -21,7 +21,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch","seq":22,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"},"isError":false,"resultSummary":"## dynamic\n- dyn-1: snapshot-marker [active]"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":23,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[21],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":23,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} diff --git a/examples/tui-agent/tests/snapshots/code-mode/session.jsonl b/examples/tui-agent/tests/snapshots/code-mode/session.jsonl index 8ee160ba26..84a4879050 100644 --- a/examples/tui-agent/tests/snapshots/code-mode/session.jsonl +++ b/examples/tui-agent/tests/snapshots/code-mode/session.jsonl @@ -1,150 +1,219 @@ -{"type":"session","version":0,"id":"94cd1ae4-e1d1-4ec8-9d27-50a1f849b6b3","createdAt":1783611771392,"cwd":"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-BteTVR","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783611771394,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783611771394,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool twice — exactly `echo CODE_ONE` then exactly `echo CODE_TWO` — and return the two outputs joined with a plus sign. Then reply with that joined string only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783611771396,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783611771396,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783611771869,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783611771869,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783611771978,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783611772007,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783611772008,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":9,"time":1783611772036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":10,"time":1783611772036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":11,"time":1783611772036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_code"}}} -{"type":"assistant/chunk","seq":12,"time":1783611772036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" program"}}} -{"type":"assistant/chunk","seq":13,"time":1783611772036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":14,"time":1783611772089,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" calls"}}} -{"type":"assistant/chunk","seq":15,"time":1783611772089,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} -{"type":"assistant/chunk","seq":16,"time":1783611772089,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" twice"}}} -{"type":"assistant/chunk","seq":17,"time":1783611772096,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":18,"time":1783611772124,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} -{"type":"assistant/chunk","seq":19,"time":1783611772153,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}} -{"type":"assistant/chunk","seq":20,"time":1783611772183,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":21,"time":1783611772183,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" two"}}} -{"type":"assistant/chunk","seq":22,"time":1783611772211,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" outputs"}}} -{"type":"assistant/chunk","seq":23,"time":1783611772212,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" joined"}}} -{"type":"assistant/chunk","seq":24,"time":1783611772212,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":25,"time":1783611772212,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":26,"time":1783611772212,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" plus"}}} -{"type":"assistant/chunk","seq":27,"time":1783611772212,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sign"}}} -{"type":"assistant/chunk","seq":28,"time":1783611772241,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":29,"time":1783611772241,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":30,"time":1783611772241,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":31,"time":1783611772241,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} -{"type":"assistant/chunk","seq":32,"time":1783611772270,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} -{"type":"assistant/chunk","seq":33,"time":1783611772270,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":34,"time":1783611772361,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":35,"time":1783611772361,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":36,"time":1783611772361,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":37,"time":1783611772362,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":38,"time":1783611772390,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"code"}}} -{"type":"assistant/chunk","seq":39,"time":1783611772390,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":40,"time":1783611772390,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":41,"time":1783611772390,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":42,"time":1783611772420,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"const"}}} -{"type":"assistant/chunk","seq":43,"time":1783611772420,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" out"}}} -{"type":"assistant/chunk","seq":44,"time":1783611772420,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"1"}}} -{"type":"assistant/chunk","seq":45,"time":1783611772420,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" ="}}} -{"type":"assistant/chunk","seq":46,"time":1783611772421,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" await"}}} -{"type":"assistant/chunk","seq":47,"time":1783611772421,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" tools"}}} -{"type":"assistant/chunk","seq":48,"time":1783611772449,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":".b"}}} -{"type":"assistant/chunk","seq":49,"time":1783611772449,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"ash"}}} -{"type":"assistant/chunk","seq":50,"time":1783611772449,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"({"}}} -{"type":"assistant/chunk","seq":51,"time":1783611772449,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" command"}}} -{"type":"assistant/chunk","seq":52,"time":1783611772478,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":":"}}} -{"type":"assistant/chunk","seq":53,"time":1783611772479,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" \\\""}}} -{"type":"assistant/chunk","seq":54,"time":1783611772479,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":55,"time":1783611772479,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" CODE"}}} -{"type":"assistant/chunk","seq":56,"time":1783611772479,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"_"}}} -{"type":"assistant/chunk","seq":57,"time":1783611772508,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"ONE"}}} -{"type":"assistant/chunk","seq":58,"time":1783611772510,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\\\","}}} -{"type":"assistant/chunk","seq":59,"time":1783611772510,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" description"}}} -{"type":"assistant/chunk","seq":60,"time":1783611772510,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":":"}}} -{"type":"assistant/chunk","seq":61,"time":1783611772510,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" \\\""}}} -{"type":"assistant/chunk","seq":62,"time":1783611772510,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"First"}}} -{"type":"assistant/chunk","seq":63,"time":1783611772538,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" echo"}}} -{"type":"assistant/chunk","seq":64,"time":1783611772538,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\\\""}}} -{"type":"assistant/chunk","seq":65,"time":1783611772566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" });\\n"}}} -{"type":"assistant/chunk","seq":66,"time":1783611772567,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"const"}}} -{"type":"assistant/chunk","seq":67,"time":1783611772567,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" out"}}} -{"type":"assistant/chunk","seq":68,"time":1783611772567,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"2"}}} -{"type":"assistant/chunk","seq":69,"time":1783611772567,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" ="}}} -{"type":"assistant/chunk","seq":70,"time":1783611772567,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" await"}}} -{"type":"assistant/chunk","seq":71,"time":1783611772597,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" tools"}}} -{"type":"assistant/chunk","seq":72,"time":1783611772597,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":".b"}}} -{"type":"assistant/chunk","seq":73,"time":1783611772597,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"ash"}}} -{"type":"assistant/chunk","seq":74,"time":1783611772598,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"({"}}} -{"type":"assistant/chunk","seq":75,"time":1783611772598,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" command"}}} -{"type":"assistant/chunk","seq":76,"time":1783611772598,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":":"}}} -{"type":"assistant/chunk","seq":77,"time":1783611772625,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" \\\""}}} -{"type":"assistant/chunk","seq":78,"time":1783611772625,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":79,"time":1783611772625,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" CODE"}}} -{"type":"assistant/chunk","seq":80,"time":1783611772626,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"_T"}}} -{"type":"assistant/chunk","seq":81,"time":1783611772626,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"WO"}}} -{"type":"assistant/chunk","seq":82,"time":1783611772626,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\\\","}}} -{"type":"assistant/chunk","seq":83,"time":1783611772654,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" description"}}} -{"type":"assistant/chunk","seq":84,"time":1783611772655,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":":"}}} -{"type":"assistant/chunk","seq":85,"time":1783611772655,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" \\\""}}} -{"type":"assistant/chunk","seq":86,"time":1783611772655,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"Second"}}} -{"type":"assistant/chunk","seq":87,"time":1783611772655,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" echo"}}} -{"type":"assistant/chunk","seq":88,"time":1783611772655,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\\\""}}} -{"type":"assistant/chunk","seq":89,"time":1783611772684,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" });\\n"}}} -{"type":"assistant/chunk","seq":90,"time":1783611772685,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"return"}}} -{"type":"assistant/chunk","seq":91,"time":1783611772685,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" out"}}} -{"type":"assistant/chunk","seq":92,"time":1783611772713,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"1"}}} -{"type":"assistant/chunk","seq":93,"time":1783611772714,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":".trim"}}} -{"type":"assistant/chunk","seq":94,"time":1783611772714,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"()"}}} -{"type":"assistant/chunk","seq":95,"time":1783611772714,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" +"}}} -{"type":"assistant/chunk","seq":96,"time":1783611772714,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" \\\"+"}}} -{"type":"assistant/chunk","seq":97,"time":1783611772744,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\\\""}}} -{"type":"assistant/chunk","seq":98,"time":1783611772744,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" +"}}} -{"type":"assistant/chunk","seq":99,"time":1783611772745,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" out"}}} -{"type":"assistant/chunk","seq":100,"time":1783611772745,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"2"}}} -{"type":"assistant/chunk","seq":101,"time":1783611772745,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":".trim"}}} -{"type":"assistant/chunk","seq":102,"time":1783611772745,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"();"}}} -{"type":"assistant/chunk","seq":103,"time":1783611772772,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":104,"time":1783611772773,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":105,"time":1783611772836,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants a single run_code program that calls bash twice, then returns the two outputs joined with a plus sign. Let me write this."}}}} -{"type":"assistant/chunk","seq":106,"time":1783611772836,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","arguments":"{\"code\": \"const out1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"First echo\\\" });\\nconst out2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Second echo\\\" });\\nreturn out1.trim() + \\\"+\\\" + out2.trim();\"}"}}}} -{"type":"assistant/chunk","seq":107,"time":1783611772836,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3009,"outputTokens":133,"cacheReadTokens":0,"reasoningTokens":29}}}} -{"type":"assistant/chunk","seq":108,"time":1783611772836,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":109,"time":1783611772840,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants a single run_code program that calls bash twice, then returns the two outputs joined with a plus sign. Let me write this."},{"type":"tool-call","id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","arguments":"{\"code\": \"const out1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"First echo\\\" });\\nconst out2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Second echo\\\" });\\nreturn out1.trim() + \\\"+\\\" + out2.trim();\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3009,"outputTokens":133,"cacheReadTokens":0,"reasoningTokens":29}},"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,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108],"surfaceOp":"append"} -{"type":"tool/call","seq":110,"time":1783611772840,"data":{"turn":1,"step":1,"callId":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","arguments":"{\"code\": \"const out1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"First echo\\\" });\\nconst out2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Second echo\\\" });\\nreturn out1.trim() + \\\"+\\\" + out2.trim();\"}"}} -{"type":"tool/code-dispatch","seq":111,"time":1783611772933,"data":{"parentCallId":"call_00_DRxnM6R1TThfDwcudW0f2050","subCallId":"call_00_DRxnM6R1TThfDwcudW0f2050:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"First echo"},"isError":false,"resultSummary":"CODE_ONE\n"}} -{"type":"tool/code-dispatch","seq":112,"time":1783611772936,"data":{"parentCallId":"call_00_DRxnM6R1TThfDwcudW0f2050","subCallId":"call_00_DRxnM6R1TThfDwcudW0f2050:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Second echo"},"isError":false,"resultSummary":"CODE_TWO\n"}} -{"type":"tool/result","seq":113,"time":1783611772937,"data":{"turn":1,"step":1,"callId":"call_00_DRxnM6R1TThfDwcudW0f2050","content":[{"type":"text","text":"CODE_ONE+CODE_TWO"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[110],"surfaceOp":"append"} -{"type":"step/end","seq":114,"time":1783611772938,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":115,"time":1783611772938,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":116,"time":1783611773376,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":117,"time":1783611773376,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":118,"time":1783611773480,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} -{"type":"assistant/chunk","seq":119,"time":1783611773511,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":120,"time":1783611773512,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":121,"time":1783611773540,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} -{"type":"assistant/chunk","seq":122,"time":1783611773541,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":123,"time":1783611773541,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":124,"time":1783611773569,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} -{"type":"assistant/chunk","seq":125,"time":1783611773570,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" for"}}} -{"type":"assistant/chunk","seq":126,"time":1783611773570,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} -{"type":"assistant/chunk","seq":127,"time":1783611773597,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" CODE"}}} -{"type":"assistant/chunk","seq":128,"time":1783611773597,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} -{"type":"assistant/chunk","seq":129,"time":1783611773597,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":130,"time":1783611773626,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"+"}}} -{"type":"assistant/chunk","seq":131,"time":1783611773627,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} -{"type":"assistant/chunk","seq":132,"time":1783611773627,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_T"}}} -{"type":"assistant/chunk","seq":133,"time":1783611773627,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} -{"type":"assistant/chunk","seq":134,"time":1783611773654,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":135,"time":1783611773655,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"CODE"}}} -{"type":"assistant/chunk","seq":136,"time":1783611773655,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_"}}} -{"type":"assistant/chunk","seq":137,"time":1783611773656,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":138,"time":1783611773656,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"+"}}} -{"type":"assistant/chunk","seq":139,"time":1783611773656,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"CODE"}}} -{"type":"assistant/chunk","seq":140,"time":1783611773657,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_T"}}} -{"type":"assistant/chunk","seq":141,"time":1783611773685,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"WO"}}} -{"type":"assistant/chunk","seq":142,"time":1783611773686,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The output is exactly what the user asked for: CODE_ONE+CODE_TWO"}}}} -{"type":"assistant/chunk","seq":143,"time":1783611773686,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CODE_ONE+CODE_TWO"}}}} -{"type":"assistant/chunk","seq":144,"time":1783611773686,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":89,"outputTokens":25,"cacheReadTokens":3072,"reasoningTokens":17}}}} -{"type":"assistant/chunk","seq":145,"time":1783611773686,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":146,"time":1783611773687,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The output is exactly what the user asked for: CODE_ONE+CODE_TWO"},{"type":"text","text":"CODE_ONE+CODE_TWO"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":89,"outputTokens":25,"cacheReadTokens":3072,"reasoningTokens":17}},"sourceEventSeqs":[116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145],"surfaceOp":"append"} -{"type":"step/end","seq":147,"time":1783611773687,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":148,"time":1783611773687,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"main-session","createdAt":1784629683717,"cwd":"/tmp/dsh-tui-snapshot-code-mode-8ohx1D"} +{"type":"turn/start","seq":0,"time":1784629683765,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1784629683765,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool twice — exactly `echo CODE_ONE` then exactly `echo CODE_TWO`. Inside that same program, console.log exactly `captured output`, then return the two outputs joined with a plus sign. Reply with that joined string only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1784629683777,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1784629683778,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1784629684210,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1784629684211,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1784629684309,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1784629684337,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1784629684337,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1784629684337,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1784629684337,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} +{"type":"assistant/chunk","seq":11,"time":1784629684365,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":12,"time":1784629684366,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":13,"time":1784629684366,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":14,"time":1784629684366,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_code"}}} +{"type":"assistant/chunk","seq":15,"time":1784629684366,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" program"}}} +{"type":"assistant/chunk","seq":16,"time":1784629684394,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":17,"time":1784629684394,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":\n"}}} +{"type":"assistant/chunk","seq":18,"time":1784629684395,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} +{"type":"assistant/chunk","seq":19,"time":1784629684395,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":20,"time":1784629684395,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Calls"}}} +{"type":"assistant/chunk","seq":21,"time":1784629684424,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":22,"time":1784629684424,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":23,"time":1784629684449,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" twice"}}} +{"type":"assistant/chunk","seq":24,"time":1784629684477,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" -"}}} +{"type":"assistant/chunk","seq":25,"time":1784629684477,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":26,"time":1784629684505,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":27,"time":1784629684505,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":28,"time":1784629684506,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":29,"time":1784629684506,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" CODE"}}} +{"type":"assistant/chunk","seq":30,"time":1784629684533,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} +{"type":"assistant/chunk","seq":31,"time":1784629684533,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":32,"time":1784629684533,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`,"}}} +{"type":"assistant/chunk","seq":33,"time":1784629684533,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":34,"time":1784629684533,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":35,"time":1784629684533,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":36,"time":1784629684561,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":37,"time":1784629684561,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" CODE"}}} +{"type":"assistant/chunk","seq":38,"time":1784629684561,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_T"}}} +{"type":"assistant/chunk","seq":39,"time":1784629684561,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} +{"type":"assistant/chunk","seq":40,"time":1784629684561,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`\n"}}} +{"type":"assistant/chunk","seq":41,"time":1784629684561,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} +{"type":"assistant/chunk","seq":42,"time":1784629684598,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":43,"time":1784629684599,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" console"}}} +{"type":"assistant/chunk","seq":44,"time":1784629684617,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".log"}}} +{"type":"assistant/chunk","seq":45,"time":1784629684618,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":46,"time":1784629684645,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":47,"time":1784629684645,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"capt"}}} +{"type":"assistant/chunk","seq":48,"time":1784629684645,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ured"}}} +{"type":"assistant/chunk","seq":49,"time":1784629684645,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":50,"time":1784629684645,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`\n"}}} +{"type":"assistant/chunk","seq":51,"time":1784629684646,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"3"}}} +{"type":"assistant/chunk","seq":52,"time":1784629684674,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":53,"time":1784629684675,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Return"}}} +{"type":"assistant/chunk","seq":54,"time":1784629684701,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":55,"time":1784629684701,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" two"}}} +{"type":"assistant/chunk","seq":56,"time":1784629684701,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" outputs"}}} +{"type":"assistant/chunk","seq":57,"time":1784629684701,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" joined"}}} +{"type":"assistant/chunk","seq":58,"time":1784629684701,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":59,"time":1784629684728,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":60,"time":1784629684728,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" plus"}}} +{"type":"assistant/chunk","seq":61,"time":1784629684729,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sign"}}} +{"type":"assistant/chunk","seq":62,"time":1784629684729,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\n\n"}}} +{"type":"assistant/chunk","seq":63,"time":1784629684729,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} +{"type":"assistant/chunk","seq":64,"time":1784629684729,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":65,"time":1784629684757,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} +{"type":"assistant/chunk","seq":66,"time":1784629684758,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":67,"time":1784629684758,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" carefully"}}} +{"type":"assistant/chunk","seq":68,"time":1784629684758,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":69,"time":1784629684841,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":70,"time":1784629684841,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":71,"time":1784629684868,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":72,"time":1784629684869,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":73,"time":1784629684869,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"code"}}} +{"type":"assistant/chunk","seq":74,"time":1784629684869,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":75,"time":1784629684897,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":76,"time":1784629684897,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":77,"time":1784629684897,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"const"}}} +{"type":"assistant/chunk","seq":78,"time":1784629684897,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" result"}}} +{"type":"assistant/chunk","seq":79,"time":1784629684924,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"1"}}} +{"type":"assistant/chunk","seq":80,"time":1784629684925,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" ="}}} +{"type":"assistant/chunk","seq":81,"time":1784629684925,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" await"}}} +{"type":"assistant/chunk","seq":82,"time":1784629684925,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" tools"}}} +{"type":"assistant/chunk","seq":83,"time":1784629684925,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":".b"}}} +{"type":"assistant/chunk","seq":84,"time":1784629684953,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"ash"}}} +{"type":"assistant/chunk","seq":85,"time":1784629684953,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"({"}}} +{"type":"assistant/chunk","seq":86,"time":1784629684953,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" command"}}} +{"type":"assistant/chunk","seq":87,"time":1784629684953,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":88,"time":1784629684953,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":89,"time":1784629684953,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":90,"time":1784629684981,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" CODE"}}} +{"type":"assistant/chunk","seq":91,"time":1784629684981,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"_"}}} +{"type":"assistant/chunk","seq":92,"time":1784629684981,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"ONE"}}} +{"type":"assistant/chunk","seq":93,"time":1784629684981,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"\\\","}}} +{"type":"assistant/chunk","seq":94,"time":1784629684981,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" description"}}} +{"type":"assistant/chunk","seq":95,"time":1784629684981,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":96,"time":1784629685009,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":97,"time":1784629685010,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"First"}}} +{"type":"assistant/chunk","seq":98,"time":1784629685037,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" echo"}}} +{"type":"assistant/chunk","seq":99,"time":1784629685038,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"\\\""}}} +{"type":"assistant/chunk","seq":100,"time":1784629685068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" });\\n"}}} +{"type":"assistant/chunk","seq":101,"time":1784629685068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"const"}}} +{"type":"assistant/chunk","seq":102,"time":1784629685068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" result"}}} +{"type":"assistant/chunk","seq":103,"time":1784629685068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"2"}}} +{"type":"assistant/chunk","seq":104,"time":1784629685068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" ="}}} +{"type":"assistant/chunk","seq":105,"time":1784629685069,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" await"}}} +{"type":"assistant/chunk","seq":106,"time":1784629685096,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" tools"}}} +{"type":"assistant/chunk","seq":107,"time":1784629685096,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":".b"}}} +{"type":"assistant/chunk","seq":108,"time":1784629685096,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"ash"}}} +{"type":"assistant/chunk","seq":109,"time":1784629685096,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"({"}}} +{"type":"assistant/chunk","seq":110,"time":1784629685096,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" command"}}} +{"type":"assistant/chunk","seq":111,"time":1784629685097,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":112,"time":1784629685129,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":113,"time":1784629685130,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":114,"time":1784629685130,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" CODE"}}} +{"type":"assistant/chunk","seq":115,"time":1784629685130,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"_T"}}} +{"type":"assistant/chunk","seq":116,"time":1784629685130,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"WO"}}} +{"type":"assistant/chunk","seq":117,"time":1784629685130,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"\\\","}}} +{"type":"assistant/chunk","seq":118,"time":1784629685152,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" description"}}} +{"type":"assistant/chunk","seq":119,"time":1784629685153,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":120,"time":1784629685153,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":121,"time":1784629685153,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"Second"}}} +{"type":"assistant/chunk","seq":122,"time":1784629685153,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" echo"}}} +{"type":"assistant/chunk","seq":123,"time":1784629685153,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"\\\""}}} +{"type":"assistant/chunk","seq":124,"time":1784629685180,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" });\\n\\n"}}} +{"type":"assistant/chunk","seq":125,"time":1784629685181,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"const"}}} +{"type":"assistant/chunk","seq":126,"time":1784629685181,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":127,"time":1784629685181,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"1"}}} +{"type":"assistant/chunk","seq":128,"time":1784629685209,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" ="}}} +{"type":"assistant/chunk","seq":129,"time":1784629685209,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" result"}}} +{"type":"assistant/chunk","seq":130,"time":1784629685209,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"1"}}} +{"type":"assistant/chunk","seq":131,"time":1784629685209,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":".stdout"}}} +{"type":"assistant/chunk","seq":132,"time":1784629685237,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":".text"}}} +{"type":"assistant/chunk","seq":133,"time":1784629685266,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":".trim"}}} +{"type":"assistant/chunk","seq":134,"time":1784629685266,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"();\\n"}}} +{"type":"assistant/chunk","seq":135,"time":1784629685266,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"const"}}} +{"type":"assistant/chunk","seq":136,"time":1784629685266,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":137,"time":1784629685266,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"2"}}} +{"type":"assistant/chunk","seq":138,"time":1784629685293,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" ="}}} +{"type":"assistant/chunk","seq":139,"time":1784629685293,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" result"}}} +{"type":"assistant/chunk","seq":140,"time":1784629685293,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"2"}}} +{"type":"assistant/chunk","seq":141,"time":1784629685293,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":".stdout"}}} +{"type":"assistant/chunk","seq":142,"time":1784629685293,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":".text"}}} +{"type":"assistant/chunk","seq":143,"time":1784629685294,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":".trim"}}} +{"type":"assistant/chunk","seq":144,"time":1784629685322,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"();\\n\\n"}}} +{"type":"assistant/chunk","seq":145,"time":1784629685322,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"console"}}} +{"type":"assistant/chunk","seq":146,"time":1784629685322,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":".log"}}} +{"type":"assistant/chunk","seq":147,"time":1784629685322,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"(\\\""}}} +{"type":"assistant/chunk","seq":148,"time":1784629685322,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"capt"}}} +{"type":"assistant/chunk","seq":149,"time":1784629685322,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"ured"}}} +{"type":"assistant/chunk","seq":150,"time":1784629685350,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" output"}}} +{"type":"assistant/chunk","seq":151,"time":1784629685351,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"\\\");\\n\\n"}}} +{"type":"assistant/chunk","seq":152,"time":1784629685379,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"return"}}} +{"type":"assistant/chunk","seq":153,"time":1784629685379,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":154,"time":1784629685428,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"1"}}} +{"type":"assistant/chunk","seq":155,"time":1784629685429,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" +"}}} +{"type":"assistant/chunk","seq":156,"time":1784629685429,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" \\\"+"}}} +{"type":"assistant/chunk","seq":157,"time":1784629685445,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"\\\""}}} +{"type":"assistant/chunk","seq":158,"time":1784629685446,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" +"}}} +{"type":"assistant/chunk","seq":159,"time":1784629685446,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":160,"time":1784629685446,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"2"}}} +{"type":"assistant/chunk","seq":161,"time":1784629685446,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":";\\n"}}} +{"type":"assistant/chunk","seq":162,"time":1784629685472,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":163,"time":1784629685472,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":164,"time":1784629685531,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to write a single run_code program that:\n1. Calls bash tool twice - first with `echo CODE_ONE`, then with `echo CODE_TWO`\n2. console.log exactly `captured output`\n3. Return the two outputs joined with a plus sign\n\nLet me write this carefully."}}}} +{"type":"assistant/chunk","seq":165,"time":1784629685531,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","arguments":"{\"code\": \"const result1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"First echo\\\" });\\nconst result2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Second echo\\\" });\\n\\nconst out1 = result1.stdout.text.trim();\\nconst out2 = result2.stdout.text.trim();\\n\\nconsole.log(\\\"captured output\\\");\\n\\nreturn out1 + \\\"+\\\" + out2;\\n\"}"}}}} +{"type":"assistant/chunk","seq":166,"time":1784629685531,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3986,"outputTokens":192,"cacheReadTokens":256,"reasoningTokens":64}}}} +{"type":"assistant/chunk","seq":167,"time":1784629685531,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":168,"time":1784629685533,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single run_code program that:\n1. Calls bash tool twice - first with `echo CODE_ONE`, then with `echo CODE_TWO`\n2. console.log exactly `captured output`\n3. Return the two outputs joined with a plus sign\n\nLet me write this carefully."},{"type":"tool-call","id":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","arguments":"{\"code\": \"const result1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"First echo\\\" });\\nconst result2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Second echo\\\" });\\n\\nconst out1 = result1.stdout.text.trim();\\nconst out2 = result2.stdout.text.trim();\\n\\nconsole.log(\\\"captured output\\\");\\n\\nreturn out1 + \\\"+\\\" + out2;\\n\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3986,"outputTokens":192,"cacheReadTokens":256,"reasoningTokens":64}},"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,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167],"surfaceOp":"append"} +{"type":"tool/call","seq":169,"time":1784629685534,"data":{"turn":1,"step":1,"callId":"call_00_38G57TcLdORe1VBnQlXL4801","name":"run_code","arguments":"{\"code\": \"const result1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"First echo\\\" });\\nconst result2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Second echo\\\" });\\n\\nconst out1 = result1.stdout.text.trim();\\nconst out2 = result2.stdout.text.trim();\\n\\nconsole.log(\\\"captured output\\\");\\n\\nreturn out1 + \\\"+\\\" + out2;\\n\"}"}} +{"type":"tool/code-dispatch","seq":170,"time":1784629685618,"data":{"parentCallId":"call_00_38G57TcLdORe1VBnQlXL4801","subCallId":"call_00_38G57TcLdORe1VBnQlXL4801:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"First echo"},"isError":false,"resultSummary":"CODE_ONE\n"}} +{"type":"tool/code-dispatch","seq":171,"time":1784629685621,"data":{"parentCallId":"call_00_38G57TcLdORe1VBnQlXL4801","subCallId":"call_00_38G57TcLdORe1VBnQlXL4801:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Second echo"},"isError":false,"resultSummary":"CODE_TWO\n"}} +{"type":"tool/result","seq":172,"time":1784629685623,"data":{"turn":1,"step":1,"callId":"call_00_38G57TcLdORe1VBnQlXL4801","content":[{"type":"text","text":"captured output\nCODE_ONE+CODE_TWO"}],"isError":false},"sourceEventSeqs":[169],"surfaceOp":"append"} +{"type":"step/end","seq":173,"time":1784629685623,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":174,"time":1784629685624,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":175,"time":1784629685973,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":176,"time":1784629685973,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":177,"time":1784629686103,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":178,"time":1784629686129,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":179,"time":1784629686129,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":180,"time":1784629686130,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":181,"time":1784629686162,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":182,"time":1784629686162,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":183,"time":1784629686162,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":184,"time":1784629686184,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" joined"}}} +{"type":"assistant/chunk","seq":185,"time":1784629686184,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" string"}}} +{"type":"assistant/chunk","seq":186,"time":1784629686184,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" only"}}} +{"type":"assistant/chunk","seq":187,"time":1784629686184,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":188,"time":1784629686215,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":189,"time":1784629686215,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":190,"time":1784629686215,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":191,"time":1784629686241,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" joined"}}} +{"type":"assistant/chunk","seq":192,"time":1784629686242,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" string"}}} +{"type":"assistant/chunk","seq":193,"time":1784629686242,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":194,"time":1784629686242,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":195,"time":1784629686269,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} +{"type":"assistant/chunk","seq":196,"time":1784629686269,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} +{"type":"assistant/chunk","seq":197,"time":1784629686269,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":198,"time":1784629686269,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"+"}}} +{"type":"assistant/chunk","seq":199,"time":1784629686269,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} +{"type":"assistant/chunk","seq":200,"time":1784629686269,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_T"}}} +{"type":"assistant/chunk","seq":201,"time":1784629686300,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} +{"type":"assistant/chunk","seq":202,"time":1784629686300,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"`."}}} +{"type":"assistant/chunk","seq":203,"time":1784629686300,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":204,"time":1784629686300,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"CODE"}}} +{"type":"assistant/chunk","seq":205,"time":1784629686301,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_"}}} +{"type":"assistant/chunk","seq":206,"time":1784629686333,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":207,"time":1784629686333,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"+"}}} +{"type":"assistant/chunk","seq":208,"time":1784629686333,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"CODE"}}} +{"type":"assistant/chunk","seq":209,"time":1784629686333,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_T"}}} +{"type":"assistant/chunk","seq":210,"time":1784629686333,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"WO"}}} +{"type":"assistant/chunk","seq":211,"time":1784629686333,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to reply with that joined string only and stop. The joined string is `CODE_ONE+CODE_TWO`."}}}} +{"type":"assistant/chunk","seq":212,"time":1784629686333,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CODE_ONE+CODE_TWO"}}}} +{"type":"assistant/chunk","seq":213,"time":1784629686333,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":105,"outputTokens":35,"cacheReadTokens":4352,"reasoningTokens":27}}}} +{"type":"assistant/chunk","seq":214,"time":1784629686333,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":215,"time":1784629686334,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user asked me to reply with that joined string only and stop. The joined string is `CODE_ONE+CODE_TWO`."},{"type":"text","text":"CODE_ONE+CODE_TWO"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":105,"outputTokens":35,"cacheReadTokens":4352,"reasoningTokens":27}},"sourceEventSeqs":[175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214],"surfaceOp":"append"} +{"type":"step/end","seq":216,"time":1784629686334,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":217,"time":1784629686334,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt b/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt index 0c51281584..af14b32791 100644 --- a/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt @@ -1,7 +1,7 @@ -terminal 100x36 buffer=normal length=36 base=0 viewport=0 +terminal 100x36 buffer=normal length=38 base=2 viewport=2 lifecycle started=1 stopped=0 progress=inactive title "Using ONE run_code program: call — DSH TUI snapshot" -cursor hidden column=1 viewportRow=27 bufferRow=27 +cursor hidden column=1 viewportRow=31 bufferRow=33 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold @@ -20,50 +20,70 @@ buffer style 0-0 fg=bright-blue style 65-77 fg=cyan style 92-99 fg=cyan -7| "▌ CODE_TWO — and return the two outputs joined with a plus sign. Then reply with that joined string " +7| "▌ CODE_TWO. Inside that same program, console.log exactly captured output, then return the two " style 0-0 fg=bright-blue style 2-9 fg=cyan -8| "▌ only and stop. " + style 58-72 fg=cyan +8| "▌ outputs joined with a plus sign. Reply with that joined string only and stop. " style 0-0 fg=bright-blue 9| "▌ " style 0-0 fg=bright-blue 10| 11| " Reasoning " style 1-9 fg=bright-black italic -12| " The user wants a single run_code program that calls bash twice, then returns the two outputs " - style 1-99 fg=bright-black italic -13| " joined with a plus sign. Let me write this. " - style 1-43 fg=bright-black italic -14| -15| "▌ " +12| " The user wants me to write a single run_code program that: " + style 1-58 fg=bright-black italic +13| " 1. Calls bash tool twice - first with echo CODE_ONE, then with echo CODE_TWO " + style 1-3 fg=bright-blue + style 4-38 fg=bright-black italic + style 39-51 fg=cyan + style 52-63 fg=bright-black italic + style 64-76 fg=cyan +14| " 2. console.log exactly captured output " + style 1-3 fg=bright-blue + style 4-23 fg=bright-black italic + style 24-38 fg=cyan +15| " 3. Return the two outputs joined with a plus sign " + style 1-3 fg=bright-blue + style 4-49 fg=bright-black italic +16| " " +17| " Let me write this carefully. " + style 1-28 fg=bright-black italic +18| +19| "▌ " style 0-0 fg=green -16| "▌ ✓ const out1 = await tools.bash({ command: \"echo CODE_ONE\", description: \"First echo\" }); " +20| "▌ ✓ const result1 = await tools.bash({ command: \"echo CODE_ONE\", description: \"First echo\" }); " style 0-0 fg=green style 2-2 fg=green bold style 3-99 bold -17| "▌ const o " +21| "▌ cons " style 0-0 fg=green - style 2-8 bold -18| "▌ CODE_ONE+CODE_TWO " + style 2-5 bold +22| "▌ captured output " style 0-0 fg=green -19| "▌ " +23| "▌ CODE_ONE+CODE_TWO " style 0-0 fg=green -20| -21| " Reasoning " +24| "▌ " + style 0-0 fg=green +25| +26| " Reasoning " style 1-9 fg=bright-black italic -22| " The output is exactly what the user asked for: CODE_ONE+CODE_TWO " - style 1-64 fg=bright-black italic -23| -24| " Assistant " +27| " The user asked me to reply with that joined string only and stop. The joined string is " + style 1-99 fg=bright-black italic +28| " CODE_ONE+CODE_TWO. " + style 1-17 fg=cyan + style 18-18 fg=bright-black italic +29| +30| " Assistant " style 1-9 fg=bright-magenta bold -25| " CODE_ONE+CODE_TWO " -26| "────────────────────────────────────────────────────────────────────────────────────────────────────" +31| " CODE_ONE+CODE_TWO " +32| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -27| " " +33| " " style 1-1 inverse -28| "────────────────────────────────────────────────────────────────────────────────────────────────────" +34| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -29| "deepseek-v4-flash /workspace/project ↑3.1k ↓158 cache 50% 3% context tools:" +35| "deepseek-v4-flash /workspace/project ↑4.1k ↓227 cache 53% 4% context tools:" style 0-79 dim style 82-99 dim -30-35| +36-37| diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt index 6508e75b19..c22acfd95b 100644 --- a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt @@ -101,6 +101,6 @@ buffer style 1-1 inverse 46| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -47| "deepseek-v4-flash /workspace/project ↑18 ↓18 cache 0% 7% cont" +47| "deepseek-v4-flash /workspace/project ↑18 ↓18 cache 0% 8% cont" style 0-90 dim style 93-99 dim diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index f83df567f4..e58145ee67 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 094e594661..81770b1595 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -11,8 +11,9 @@ import { Service, type Context } from 'cordis' import z from 'schemastery' import { isAbsolute, resolve as resolvePath } from 'node:path' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools' import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' +import { HarnessError } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-session-persistence' import type {} from '@deepseek-ai/dsh-system-prompt' @@ -22,7 +23,7 @@ import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandb import { ESCALATION_TARGETS, approveEscalation, canonicalPath, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash' -import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash' +import type { BashRunResult, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash' import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-paths' import { processOutcome } from './background.ts' import { parseExitStatus, renderProcessRead, renderResult } from './render.ts' @@ -206,7 +207,7 @@ export class BashEnvRegistry extends Service { } } -/** Parsed tool args; execute validates value constraints absent from SchemaSpec. */ +/** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */ interface BashToolArgs { command: string description: string @@ -318,6 +319,38 @@ function resolveWorkdir( return modelWorkdir } +/** Detach the executor DTO from readonly seam interfaces into plain JSON data. */ +function canonicalBashResult(result: BashRunResult) { + const output = (stream: BashRunResult['stdout']) => ({ + text: stream.text, + truncated: stream.truncated, + ...stream.spillPath !== undefined ? { spillPath: stream.spillPath } : {}, + }) + return { + exitCode: result.exitCode, + signal: result.signal, + timedOut: result.timedOut, + aborted: result.aborted, + timeoutMs: result.timeoutMs, + stdout: output(result.stdout), + stderr: output(result.stderr), + ...result.sandbox !== undefined ? { + sandbox: { + mode: result.sandbox.mode, + denied: result.sandbox.denied, + ...result.sandbox.enforcement !== undefined ? { enforcement: result.sandbox.enforcement } : {}, + ...result.sandbox.runnerFailed !== undefined ? { runnerFailed: result.sandbox.runnerFailed } : {}, + }, + } : {}, + } +} + +/** Canonical background-handle properties shared by the bash output union. */ +const BACKGROUND_OUTPUT_PROPERTIES = { + kind: { type: 'string', required: true, const: 'background' }, + taskId: { type: 'string', required: true }, +} as const + export function apply(ctx: Context, config: Config = {}): void { const bashEnv = new BashEnvRegistry(ctx, config) bashEnv.register({ @@ -415,6 +448,65 @@ export function apply(ctx: Context, config: Config = {}): void { }, } : {}, }, + output: { + schema: { + oneOf: [ + { + type: 'object', + additionalProperties: false, + properties: BACKGROUND_OUTPUT_PROPERTIES, + }, + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'foreground' }, + exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] }, + signal: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] }, + timedOut: { type: 'boolean', required: true }, + aborted: { type: 'boolean', required: true }, + timeoutMs: { type: 'number', required: true }, + stdout: { + type: 'object', + additionalProperties: false, + required: true, + properties: { + text: { type: 'string', required: true }, + truncated: { type: 'boolean', required: true }, + spillPath: { type: 'string' }, + }, + }, + stderr: { + type: 'object', + additionalProperties: false, + required: true, + properties: { + text: { type: 'string', required: true }, + truncated: { type: 'boolean', required: true }, + spillPath: { type: 'string' }, + }, + }, + sandbox: { + type: 'object', + additionalProperties: false, + properties: { + mode: { type: 'string', required: true }, + denied: { type: 'boolean', required: true }, + enforcement: { type: 'string' }, + runnerFailed: { type: 'boolean' }, + }, + }, + }, + }, + ], + }, + render: (_args, value) => [{ + type: 'text', + text: value.kind === 'background' + ? `started background task ${value.taskId}` + : renderResult(value as { kind: 'foreground' } & BashRunResult, escalationModes), + }], + }, async execute(args: BashToolArgs, exec) { validateBashArgs(args) // Description is display metadata; workdir defaults to the caller's session. @@ -444,7 +536,11 @@ export function apply(ctx: Context, config: Config = {}): void { throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks') } // The caller owns cancellation until TaskService commits detached ownership. - if (exec.signal.aborted) return [] + if (exec.signal.aborted) { + const error = new HarnessError('tool call aborted', TOOL_ABORTED) + error.name = 'AbortError' + throw error + } // Task preflight finishes before the starter can spawn a process. const id = tasks.start({ kind: 'bash', @@ -459,14 +555,14 @@ export function apply(ctx: Context, config: Config = {}): void { } }, }) - return [{ type: 'text', text: `started background task ${id}` }] + return { kind: 'background' as const, taskId: id } } const result = await ctx.bash.run(ctx.bash.resolve({ ...request, signal: exec.signal, })) if (result.aborted) throw new Error('command aborted') - return [{ type: 'text', text: renderResult(result, escalationModes) }] + return { kind: 'foreground' as const, ...canonicalBashResult(result) } }, presentCall: presentBashCall, presentResult: presentBashResult, diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index f430ca07a1..8811da6ca0 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -122,7 +122,13 @@ class RecordingSandboxExecutor extends BashExecutor { timeoutMs: spec.timeoutMs, stdout: { text: 'ok', truncated: false }, stderr: { text: '', truncated: false }, - sandbox: { mode: spec.sandboxPolicy?.mode ?? 'read-only', denied: false }, + sandbox: { + mode: spec.sandboxPolicy?.mode ?? 'read-only', + denied: false, + ...spec.command === 'without optional sandbox facts' + ? {} + : { enforcement: 'full' as const, runnerFailed: false }, + }, }) } @@ -213,6 +219,16 @@ describe('bash tool', () => { const ctx = await setup() const result = await call(ctx, 'bash', { command: 'echo hello', description: 'test command' }) expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected bash success') + expect(result.value).toMatchObject({ + kind: 'foreground', + exitCode: 0, + signal: null, + timedOut: false, + aborted: false, + stdout: { text: 'hello\n', truncated: false }, + stderr: { text: '', truncated: false }, + }) expect(text(result)).toBe('hello\n') }) @@ -296,7 +312,7 @@ describe('bash tool', () => { }) // Type and required-key violations are rejected by the harness - // (defineTool validates against the SchemaSpec — the arg-validation Agent Note) before execute. + // (defineTool validates against the ParameterSchemaSpec — the arg-validation Agent Note) before execute. it.each([ [{}, /missing required property "command"/], [{ command: 42, description: 'd' }, /"command" must be a string/], @@ -312,7 +328,7 @@ describe('bash tool', () => { expect(text(result)).toMatch(pattern) }) - // Value constraints the SchemaSpec can't express stay in the tool body. + // Value constraints the ParameterSchemaSpec can't express stay in the tool body. it.each([ [{ command: ' ', description: 'd' }, /invalid command/], [{ command: 'x', description: ' ' }, /invalid description/], @@ -408,6 +424,8 @@ describe('background execution through the task runtime', () => { const ctx = await setupWithTasks() const started = await call(ctx, 'bash', { command: 'echo bg-ok', description: 'test command', run_in_background: true }) expect(started.isError).toBe(false) + if (started.isError) throw new Error('expected background bash success') + expect(started.value).toEqual({ kind: 'background', taskId: 'bash-1' }) expect(text(started)).toBe('started background task bash-1') const read = await callUntilText(ctx, 'task_output', { task_id: 'bash-1' }, 'bg-ok') @@ -479,7 +497,10 @@ describe('background execution through the task runtime', () => { signal: controller.signal, }) expect(result.isError).toBe(true) - expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }) + expect(result.error).toEqual({ + message: 'tool call aborted before dispatch', + info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + }) expect(text(result)).toBe('Error: tool call aborted before dispatch') expect((ctx.bash as CountingStartExecutor).starts).toBe(0) }) @@ -633,7 +654,10 @@ describe('sandbox escalation through the generic task producer', () => { signal: controller.signal, }) - expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED }) + expect(result.error).toEqual({ + message: 'tool call aborted', + info: { name: 'AbortError', code: TOOL_ABORTED }, + }) expect(text(result)).toBe('Error: tool call aborted') expect(start).not.toHaveBeenCalled() }) @@ -647,6 +671,22 @@ describe('sandbox escalation through the generic task producer', () => { expect(bash.modes).toEqual(['workspace-write', 'danger-full-access']) }) + it('omits sandbox facts the executor did not acquire from the canonical result', async () => { + const { ctx } = await setupSandboxed() + const result = await call(ctx, 'bash', { + command: 'without optional sandbox facts', + description: 'exercise optional sandbox facts', + }) + + if (result.isError) throw new Error('expected foreground bash success') + expect(result.value).toMatchObject({ + kind: 'foreground', + sandbox: { mode: 'read-only', denied: false }, + }) + expect((result.value as { sandbox: object }).sandbox).not.toHaveProperty('enforcement') + expect((result.value as { sandbox: object }).sandbox).not.toHaveProperty('runnerFailed') + }) + it('keeps the exhaustiveness backstop for a rogue approval implementation', async () => { const { ctx } = await setupSandboxed(true) ctx.approval.request = () => Promise.resolve('rogue' as ApprovalOutcome) diff --git a/packages/code-runtime/code-runtime-worker/README.md b/packages/code-runtime/code-runtime-worker/README.md index 2645e8810e..1838919112 100644 --- a/packages/code-runtime/code-runtime-worker/README.md +++ b/packages/code-runtime/code-runtime-worker/README.md @@ -10,32 +10,33 @@ Worker-thread implementation of the [`@deepseek-ai/dsh-code-runtime`](../code-ru config: computeMs: 60000 # busy-time budget (measured event-loop active time) maxWallMs: 600000 # wall-clock ceiling; never pauses for anything - maxLogBytes: 65536 # shared byte budget for captured log text - maxValueBytes: 32768 # rendered-completion-value cap + maxOutputBytes: 67108864 # combined serialized outer-output cap (64 MiB) maxOldGenerationSizeMb: 512 # worker heap cap (resourceLimits) ``` -Every field is validated (positive numbers) and defaulted; there are no other tunables. +Every field is validated and defaulted; `maxOutputBytes` is a safe integer of at least four bytes, the remaining fields are positive finite numbers, and there are no other tunables. ## Design - **One fresh worker per run, no pooling** — a program's world dies with its worker: no cross-run state to log, state bleed unrepresentable, runs reconstructable from the session log alone. - **Type-strip host-side, in execution context** — the program is wrapped in an async-function shell, stripped with `node:module`'s `stripTypeScriptTypes` (erasable syntax only — `enum`/namespaces are rejected as a program `exception` and no worker spawns), and sliced back out byte-positioned; it then executes as the body of an `AsyncFunction`, so top-level `await`/`return` work. -- **The port assumes a hostile peer** — model code can reach `parentPort` and forge traffic, so every inbound message is shape-validated and REBUILT before anything reads it (`null`, primitives, junk types, and malformed payloads drop without a throw; forged extra fields never ride along), the host answers each call id at most once, resolves binding names as OWN properties only (a forged `constructor` cannot walk a prototype chain), drops post-settlement replies, and converts a non-cloneable binding resolution into an error reply. Forged `log`/`done` messages cannot bypass the caps: one host-side ledger bounds everything that lands in `logs`, and the completion value is re-capped host-side. Worker-side namespaces are null-prototype with `defineProperty`, so `__proto__`-shaped binding names are ordinary keys. +- **The port assumes a hostile peer** — model code can reach `parentPort` and forge traffic, so every inbound message is shape-validated and REBUILT before anything reads it (`null`, primitives, junk types, and malformed payloads drop without a throw; forged extra fields never ride along), the host answers each call id at most once, resolves binding names as OWN properties only (a forged `constructor` cannot walk a prototype chain), drops post-settlement replies, and validates every binding resolution and completion as lossless JSON. Forged `log`/`done` messages cannot bypass the outer cap: the host repeats validation and accounts every admitted log plus the completion or diagnostic. Worker-side namespaces are null-prototype with `defineProperty`, so `__proto__`-shaped binding names are ordinary keys. +- **Binding rejection classes are request data** — an optional namespace descriptor names the constructor global and the own property that receives the failed member name. The worker materializes and injects that real class, so `instanceof` works without hardcoding `tools` or `ToolCallError`; declarations with invalid or colliding globals fail before a worker spawns. Failures use module-captured error and property-definition intrinsics plus null-prototype descriptors, so later model mutations cannot turn a rejected binding into a worker crash. - **Two independent budgets, because the peer is hostile** — `computeMs` meters the worker's MEASURED busy time (`worker.performance.eventLoopUtilization()` polling): a hot loop cannot hide behind a pending decoy dispatch, and a program awaiting a slow tool accrues nothing. `maxWallMs` backstops what busy time cannot see (awaiting a promise nobody resolves). Both funnel into `worker.terminate()`, which ends hot synchronous loops too; heap overflow surfaces as the worker's OOM exit (`kind: 'worker-exit'`). -- **Logs stream eagerly** — console/stdout/stderr text crosses the port in emission order, so a timed-out or killed program still shows what it printed. ONE shared `maxLogBytes` ledger bounds everything: streamed text, forged port traffic, and pipe bytes that bypass the patched streams (appended after), with the overflow marked in-band once. +- **Intermediate binding values are complete JSON** — binding arguments and resolutions undergo iterative lossless-JSON validation. Before program execution, the worker captures its own realm's plain-container prototype identities plus the native function-source check used only for foreign realms, so constructor-slot mutation and user-authored impostors cannot change container classification. It also captures every structural and metering intrinsic used by this JSON boundary, creates property descriptors without a prototype, and bypasses mutable collection prototypes for private traversal state; model mutations of globals, prototype methods, or descriptor-shaped `Object.prototype` fields therefore cannot alter validation, wire transport, or byte accounting. Values flatten into a bounded-depth pre-order wire value for structured clone and rebuild iteratively on the other side. They have no byte, JavaScript call-stack, or nested structured-clone depth cap. They never enter the outer-output ledger or model context; provider/executor acquisition bounds and process/worker memory remain the limits. +- **Logs stream eagerly into one outer ledger** — console/stdout/stderr text crosses the port in emission order, so a timed-out or killed program still shows what it printed. The worker charges exact JSON-string bytes and preflights completion values and exception diagnostics against the remaining combined budget before posting them; a thrown million-byte stack therefore becomes the fixed `output-limit` diagnostic at the worker boundary. Native writes that bypass the patched stream slots arrive on pipes independent of the completion port, so the host repeats the ledger for those bytes and hostile forged traffic; settlement continues bounded pipe capture until worker termination completes before materializing the result. `maxOutputBytes` accounts the JSON serialization of the outer `logs` array plus the completion value or failure-message payload; fixed `CodeRunResult` field names, braces, the bounded error-kind tag, and later presentation whitespace are outside that variable-payload ledger. At or below the cap the exact value returns; a lossy completion is `invalid-output`, and a combined overflow is `output-limit` rather than a substituted inspected string. The failure retains the fitting captured prefix and later follows the normal outer `run_code` spill policy. - **Empty environment** — the worker gets `env: {}` and `execArgv: []`: no ambient credentials (stronger than the scrubbed-env rule for spawned commands) and no inherited loader flags. - **Dispose to quiescence** — teardown fails in-flight runs as `abort` and AWAITS each worker's exit before resolving. ## The worker entry, unbuilt and built -Source mode loads erasable-only `src/worker.ts` through Node's native type stripping. Built mode passes the sibling `lib/worker.cjs` as a filesystem path because pkg's VFS Worker hook expects CommonJS; the same path works under ordinary Node. `tests/built-lib.e2e.ts` pins the real load path required by [docs/testing.md](../../../docs/testing.md). +Source mode loads erasable-only `src/worker.ts` through Node's native type stripping. Its transitive runtime closure contains only Node built-ins and relative source modules, so a fresh checkout never requires a sibling workspace package's unbuilt `lib/` export. The worker-local JSON snapshotter is parity-tested against the session-owned canonical boundary; both sides flatten and rebuild validated values around the message port so application nesting never reaches structured clone. Built mode passes the sibling `lib/worker.cjs` as a filesystem path because pkg's VFS Worker hook expects CommonJS; the same path works under ordinary Node. `tests/built-lib.e2e.ts` pins the real load path required by [docs/testing.md](../../../docs/testing.md). The SDK surface is the default/named `WorkerCodeRuntime` class plus `Config`. The operational `./worker` subpath exists only as the packaged spawn entry; the wire protocol and bootstrap helpers are source-private implementation details. ## Model Experience -Indirectly, through Code Mode in [`dsh-tools`](../../core/tools/README.md), which renders this worker's capped printed or returned data and exact `[dsh-code-runtime-worker] log capture truncated at bytes` and `… [truncated]` markers into a retained `run_code` result. Binding traffic and worker internals stay outside context. +Indirectly, through Code Mode in [`dsh-tools`](../../core/tools/README.md), which renders the exact outer value when it fits or an explicit `invalid-output` / `output-limit` failure. Only the outer `run_code` result enters model context and its ordinary spill policy; binding traffic and intermediate values remain execution-local. #### KV Cache effect @@ -47,4 +48,5 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **Type-strip rides Node's experimental `stripTypeScriptTypes` API** — the relied-on behavior is pinned by unit tests, with amaro/sucrase as named drop-in replacements if it shifts. - **`computeMs` expiry can overshoot by up to one poll interval** — busy time is sampled every 25 ms (an internal constant, deliberately not config). - **Programs get a five-method `console` shim** (`log`/`info`/`warn`/`error`/`debug`) — deliberately not Node's full console surface. -- **A non-cloneable or oversize completion value does not cross as a value** — it arrives as a bounded, truncation-marked `util.inspect` rendering in `value`'s place. +- **Intermediate binding values have no byte cap** — a program can exhaust process or worker memory with a value that never becomes outer output. +- **The 64 MiB default is a rejection boundary, not recoverable storage** — outer spill can save only the bounded logs and diagnostic returned after `output-limit`; bytes rejected beyond the runtime cap never reach the spill layer. diff --git a/packages/code-runtime/code-runtime-worker/package.json b/packages/code-runtime/code-runtime-worker/package.json index f9c0f6be4c..e173842fc1 100644 --- a/packages/code-runtime/code-runtime-worker/package.json +++ b/packages/code-runtime/code-runtime-worker/package.json @@ -33,6 +33,7 @@ "peerDependencies": { "@deepseek-ai/dsh-code-runtime": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -41,6 +42,7 @@ "devDependencies": { "@deepseek-ai/dsh-code-runtime": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/code-runtime/code-runtime-worker/src/bootstrap.ts b/packages/code-runtime/code-runtime-worker/src/bootstrap.ts index 7f36364a7c..aad4b7b2e6 100644 --- a/packages/code-runtime/code-runtime-worker/src/bootstrap.ts +++ b/packages/code-runtime/code-runtime-worker/src/bootstrap.ts @@ -6,9 +6,21 @@ */ import { inspect } from 'node:util' -import { serialize } from 'node:v8' -import { logTruncationMarker } from './protocol.ts' import type { DoneMessage, ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts' +import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from './output-json.ts' +import { decodeWorkerJson, encodeWorkerJson, snapshotCodeJsonValue } from './worker-json.ts' + +const CapturedError = Error +const capturedObjectCreate = Object.create +const capturedObjectDefineProperty = Object.defineProperty + +/** Define one public binding-error field without consulting mutable globals or descriptor prototypes. */ +function defineBindingErrorField(error: Error, key: string, value: string): void { + const attributes = capturedObjectCreate(null) as PropertyDescriptor + attributes.enumerable = true + attributes.value = value + capturedObjectDefineProperty(error, key, attributes) +} /** The port surface the bootstrap needs — satisfied by `parentPort` and by the tests' fake. */ export interface BootstrapPort { @@ -27,26 +39,28 @@ export interface PatchableStream { } /** - * Ordered text capture under one shared byte budget, delivered to a sink as - * each item lands (the real sink streams text over the port eagerly, so - * captured output survives a mid-run termination). Once the budget is - * exhausted it emits exactly one in-band marker and silently drops everything - * after. The cap is a blast-radius bound, so "how much was lost" intentionally - * stays unmeasured. + * Ordered text capture under the shared outer JSON-byte budget, delivered to + * a sink as each item lands (the real sink streams text over the port eagerly, + * so captured output survives a mid-run termination). It includes the log + * array syntax and string escaping in its accounting. Once exhausted it emits + * the fitting prefix and reports the limit once; the host turns that condition + * into an explicit `output-limit` run failure. */ export class LogBuffer { - private remaining: number + private bytes = 2 // JSON serialization of the empty logs array: [] + private entries = 0 private truncated = false // Explicit fields, not constructor parameter properties: this module loads // under Node's native strip-only mode, which rejects non-erasable syntax — // and parameter properties are non-erasable. - private readonly maxBytes: number private readonly sink: (text: string) => void + private readonly onLimit: () => void + private readonly maxBytes: number - constructor(maxBytes: number, sink: (text: string) => void) { + constructor(maxBytes: number, sink: (text: string) => void, onLimit: () => void = () => {}) { this.maxBytes = maxBytes this.sink = sink - this.remaining = maxBytes + this.onLimit = onLimit } /** @@ -55,15 +69,32 @@ export class LogBuffer { */ push(text: string): void { if (this.truncated) return - const cost = Buffer.byteLength(text, 'utf8') - if (cost > this.remaining) { + const separatorBytes = this.entries > 0 ? 1 : 0 + const availableBytes = this.maxBytes - this.bytes - separatorBytes + const stringBytes = jsonStringBytesUpTo(text, availableBytes) + if (stringBytes === undefined) { this.truncated = true - this.sink(logTruncationMarker(this.maxBytes)) + const prefix = truncateJsonStringBytes(text, availableBytes) + if (prefix.length > 0) { + const prefixBytes = jsonStringBytesUpTo(prefix, availableBytes) + /* v8 ignore next -- truncateJsonStringBytes guarantees the returned prefix fits. */ + if (prefixBytes === undefined) throw new CapturedError('worker output ledger produced an oversized log prefix') + this.bytes += prefixBytes + separatorBytes + this.entries += 1 + this.sink(prefix) + } + this.onLimit() return } - this.remaining -= cost + this.bytes += stringBytes + separatorBytes + this.entries += 1 this.sink(text) } + + /** Remaining exact JSON-byte budget for the completion value or failure message. */ + remainingOutputBytes(): number { + return this.maxBytes - this.bytes + } } /** The five console methods the shim captures, in the seam's level vocabulary. */ @@ -122,59 +153,79 @@ export function captureStreamWrites(logs: LogBuffer, stream: PatchableStream): ( const INSPECT_OPTIONS = { depth: 4, maxArrayLength: 100, maxStringLength: 10_000 } as const /** - * The longest prefix of `text` whose UTF-8 encoding fits `maxBytes`, cut at - * a code-point boundary (never mid-surrogate-pair). The byte caps are BYTE - * caps — `String.prototype.slice` counts UTF-16 code units, up to 3× smaller - * than what a multibyte string actually costs across the boundary. - * @param text - the string to bound. - * @param maxBytes - the UTF-8 byte budget the prefix must fit. - * @returns the prefix (all of `text` when it already fits). + * Prepare the program's completion value for the done message. Only lossless + * JSON crosses, and a value that does not fit the remaining combined outer + * budget reports `output-limit`; the host revalidates hostile traffic and + * remains authoritative for native pipe writes the worker cannot observe. + * + * @param value - the program's completion value. + * @param remainingOutputBytes - exact bytes left after captured logs. + * @param maxOutputBytes - the configured cap named in an overflow diagnostic. + * @returns the done-message fragment: `{}` for `undefined`, else a flat wire `{ value }`. */ -export function truncateUtf8Bytes(text: string, maxBytes: number): string { - if (Buffer.byteLength(text, 'utf8') <= maxBytes) return text - let bytes = 0 - let end = 0 - for (const char of text) { - const cost = Buffer.byteLength(char, 'utf8') - if (bytes + cost > maxBytes) break - bytes += cost - end += char.length +export function prepareCompletion( + value: unknown, + remainingOutputBytes: number, + maxOutputBytes: number = remainingOutputBytes, +): Omit { + if (value === undefined) return {} + let snapshot: ReturnType + try { + snapshot = snapshotCodeJsonValue(value) + } catch { + snapshot = undefined } - return text.slice(0, end) + if (snapshot === undefined) { + return prepareFailure( + 'invalid-output', + 'program completion must be lossless JSON', + remainingOutputBytes, + maxOutputBytes, + ) + } + if (jsonValueBytesUpTo(snapshot, remainingOutputBytes) === undefined) { + return outputLimit(maxOutputBytes) + } + return { value: encodeWorkerJson(snapshot) } +} + +/** Build the fixed overflow fragment without carrying rejected variable bytes. */ +function outputLimit(maxOutputBytes: number): Omit { + return { error: { kind: 'output-limit', message: `outer output exceeded ${maxOutputBytes} bytes` } } +} + +/** Admit one bounded failure message or replace it with the fixed overflow diagnostic. */ +function prepareFailure( + kind: 'exception' | 'invalid-output', + message: string, + remainingOutputBytes: number, + maxOutputBytes: number, +): Omit { + if (jsonStringBytesUpTo(message, remainingOutputBytes) === undefined) return outputLimit(maxOutputBytes) + return { error: { kind, message } } } /** - * Prepare the program's completion value for the done message: a value whose MEASURED - * cross-boundary size fits `maxValueBytes` crosses raw — exact bytes for a string, the - * structured-clone wire size (`v8.serialize`) for everything else, so a huge container whose - * bounded inspect rendering happens to be small cannot smuggle itself past the cap. Oversized - * or non-cloneable values are replaced by a bounded string rendering with an in-band marker. - * - * @param value - the program's completion value. - * @param maxValueBytes - the byte cap for the value. - * @returns the done-message fragment: `{}` for `undefined`, else `{ value }`. + * Prepare a thrown program value without sending an unbounded stack or + * string across the worker port. + * @param error - the value thrown by the program. + * @param remainingOutputBytes - exact bytes left after captured logs. + * @param maxOutputBytes - the configured cap named in an overflow diagnostic. + * @returns a bounded exception or fixed output-limit fragment. */ -export function prepareValue(value: unknown, maxValueBytes: number): { value?: unknown } { - if (value === undefined) return {} - if (typeof value === 'string') { - if (Buffer.byteLength(value, 'utf8') <= maxValueBytes) return { value } - } else { - let size: number | undefined - try { - size = serialize(value).byteLength - } catch { - // Only the verdict matters: the value has parts the structured-clone - // algorithm rejects (functions, classes, …) and must cross as its - // rendering instead. - size = undefined - } - if (size !== undefined && size <= maxValueBytes) return { value } +export function prepareException( + error: unknown, + remainingOutputBytes: number, + maxOutputBytes: number = remainingOutputBytes, +): Omit { + let message: string + try { + const detail: unknown = error instanceof CapturedError ? error.stack ?? error.message : error + message = typeof detail === 'string' ? detail : String(detail) + } catch { + message = 'program threw an unrenderable value' } - const rendered = typeof value === 'string' ? value : inspect(value, INSPECT_OPTIONS) - const capped = Buffer.byteLength(rendered, 'utf8') > maxValueBytes - ? `${truncateUtf8Bytes(rendered, maxValueBytes)}… [truncated]` - : rendered - return { value: capped } + return prepareFailure('exception', message, remainingOutputBytes, maxOutputBytes) } /** One awaited binding call's settlement handles, keyed by call id in the pending map. */ @@ -183,6 +234,46 @@ export interface PendingCall { reject(error: Error): void } +/** Constructor shape for one program-visible binding rejection class. */ +export type BindingErrorConstructor = new (memberName: string, message: string) => Error + +/** + * Materialize the real error constructor declared by one namespace. + * @param descriptor - program-global class name and member-name property. + * @returns the constructor injected into the program and used for rejections. + */ +function makeBindingErrorClass( + descriptor: { name: string; memberNameProperty: string }, +): BindingErrorConstructor { + return class BindingCallError extends CapturedError { + constructor(memberName: string, message: string) { + super(message) + defineBindingErrorField(this, 'name', descriptor.name) + defineBindingErrorField(this, descriptor.memberNameProperty, memberName) + } + } +} + +/** Create the namespace-specific rejection for one failed binding call. */ +function bindingFailure(errorClass: BindingErrorConstructor | undefined, memberName: string, message: string): Error { + return errorClass ? new errorClass(memberName, message) : new CapturedError(message) +} + +/** + * Build each declared error class once so calls and `instanceof` share constructor identity. + * @param data - binding namespace declarations from the boot payload. + * @returns constructors keyed by their owning namespace global. + */ +export function makeBindingErrorClasses( + data: Pick, +): Map { + const classes = new Map() + for (const namespace of data.namespaces) { + if (namespace.errorClass) classes.set(namespace.global, makeBindingErrorClass(namespace.errorClass)) + } + return classes +} + /** * Route host replies into the pending-call map: each reply settles its call * at most once, and a reply for an unknown id (stray, or a duplicate answer @@ -197,8 +288,13 @@ export function wireReplies(port: BootstrapPort, pending: Map, nextId: { value: number }, + errorClasses: Map = makeBindingErrorClasses(data), ): Record[] { return data.namespaces.map(({ global, names }) => { + const errorClass = errorClasses.get(global) const namespace = Object.create(null) as Record for (const name of names) { Object.defineProperty(namespace, name, { enumerable: true, - value: (args: unknown): Promise => new Promise((resolve, reject) => { - const id = nextId.value++ - pending.set(id, { resolve, reject }) + value: (args: unknown): Promise => { + let detached: ReturnType try { - port.postMessage({ type: 'call', id, global, name, args }) - } catch (error: unknown) { - pending.delete(id) - reject(new Error(`binding arguments must be structured-cloneable: ${error instanceof Error ? error.message : String(error)}`)) + detached = snapshotCodeJsonValue(args) + } catch { + detached = undefined } - }), + if (detached === undefined) { + return Promise.reject(bindingFailure(errorClass, name, 'binding arguments must be lossless JSON')) + } + return new Promise((resolve, reject) => { + const id = nextId.value++ + pending.set(id, { + resolve, + reject: (error) => { + reject(bindingFailure(errorClass, name, error.message)) + }, + }) + try { + port.postMessage({ type: 'call', id, global, name, args: encodeWorkerJson(detached) }) + } catch (error: unknown) { + pending.delete(id) + const message = `binding arguments must be structured-cloneable: ${error instanceof CapturedError ? error.message : String(error)}` + reject(bindingFailure(errorClass, name, message)) + } + }) + }, }) } return namespace @@ -254,7 +371,11 @@ export async function runWorkerMain( data: WorkerBootData, streams: { stdout: PatchableStream; stderr: PatchableStream }, ): Promise { - const logs = new LogBuffer(data.maxLogBytes, (text) => { port.postMessage({ type: 'log', text }) }) + const logs = new LogBuffer( + data.maxOutputBytes, + (text) => { port.postMessage({ type: 'log', text }) }, + () => { port.postMessage({ type: 'output-limit' }) }, + ) captureStreamWrites(logs, streams.stdout) captureStreamWrites(logs, streams.stderr) @@ -262,7 +383,18 @@ export async function runWorkerMain( wireReplies(port, pending) const nextId = { value: 1 } - const namespaces = makeNamespaces(data, port, pending, nextId) + const errorClasses = makeBindingErrorClasses(data) + const namespaces = makeNamespaces(data, port, pending, nextId, errorClasses) + const errorClassParameters: string[] = [] + const errorClassValues: BindingErrorConstructor[] = [] + for (const namespace of data.namespaces) { + if (!namespace.errorClass) continue + errorClassParameters.push(namespace.errorClass.name) + const errorClass = errorClasses.get(namespace.global) + /* v8 ignore next -- makeBindingErrorClasses covers every declaration in the same data. */ + if (!errorClass) throw new CapturedError(`missing binding error class for ${namespace.global}`) + errorClassValues.push(errorClass) + } const consoleShim = makeConsoleShim(logs) let done: DoneMessage @@ -271,12 +403,22 @@ export async function runWorkerMain( // `AsyncFunction` is not a global. The program body is strict-mode. /* v8 ignore next -- the arrow exists only to reach the AsyncFunction constructor; it is never invoked. */ const AsyncFunction = (async () => {}).constructor as new (...args: string[]) => (...fnArgs: unknown[]) => Promise - const fn = new AsyncFunction(...data.namespaces.map(namespace => namespace.global), 'console', `'use strict';\n${data.code}`) - const value = await fn(...namespaces, consoleShim) - done = { type: 'done', ...prepareValue(value, data.maxValueBytes) } + const fn = new AsyncFunction( + ...data.namespaces.map(namespace => namespace.global), + ...errorClassParameters, + 'console', + `'use strict';\n${data.code}`, + ) + const value = await fn(...namespaces, ...errorClassValues, consoleShim) + done = { + type: 'done', + ...prepareCompletion(value, logs.remainingOutputBytes(), data.maxOutputBytes), + } } catch (error: unknown) { - const message = error instanceof Error ? error.stack ?? error.message : String(error) - done = { type: 'done', error: { message } } + done = { + type: 'done', + ...prepareException(error, logs.remainingOutputBytes(), data.maxOutputBytes), + } } port.postMessage(done) } diff --git a/packages/code-runtime/code-runtime-worker/src/index.ts b/packages/code-runtime/code-runtime-worker/src/index.ts index 4a13baa07c..e7eae65f2b 100644 --- a/packages/code-runtime/code-runtime-worker/src/index.ts +++ b/packages/code-runtime/code-runtime-worker/src/index.ts @@ -8,14 +8,17 @@ import { Worker } from 'node:worker_threads' import { stripTypeScriptTypes } from 'node:module' +import type { Readable } from 'node:stream' import { fileURLToPath } from 'node:url' import { Context } from 'cordis' import z from 'schemastery' import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' -import type { CodeBindingFunction, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' -import { prepareValue, truncateUtf8Bytes } from './bootstrap.ts' -import { logTruncationMarker } from './protocol.ts' +import type { CodeBindingNamespace, CodeJsonValue, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' +import { snapshotJsonValue } from '@deepseek-ai/dsh-session' import type { ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts' +import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from './output-json.ts' +import { decodeWorkerJson, encodeWorkerJson } from './worker-json.ts' +import type { WorkerJsonWire } from './worker-json.ts' /** Plugin config: every execution cap, changeable from `cordis.yml` (no hardcoded tunables). */ export interface Config { @@ -35,14 +38,11 @@ export interface Config { * nobody will resolve). */ maxWallMs?: number - /** Shared byte budget for captured log text (console + raw stream writes), truncation marked in-band. */ - maxLogBytes?: number /** - * Byte cap for the completion value, measured by its real cross-boundary - * size (string bytes, or structured-clone wire size); an oversized or - * non-cloneable value crosses as a capped string rendering. + * Hard cap for serialized log-array, completion-value, and failure-message payloads; + * fixed result-envelope syntax is excluded. */ - maxValueBytes?: number + maxOutputBytes?: number /** The worker's max old-generation heap in MiB (`resourceLimits`); overflow kills the worker, surfacing as kind `'worker-exit'`. */ maxOldGenerationSizeMb?: number } @@ -59,6 +59,9 @@ type ResolvedConfig = Required */ const ELU_POLL_INTERVAL_MS = 25 +/** Smallest cap that can represent the counted payloads: an empty logs array plus an empty JSON failure message. */ +const MIN_OUTPUT_BYTES = 4 + /** ECMAScript reserved words that cannot be async-function parameter names — rejected as binding globals. */ const RESERVED_WORDS = new Set([ 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do', @@ -71,6 +74,9 @@ const RESERVED_WORDS = new Set([ /** Valid async-function parameter name (the binding global becomes one). */ const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/ +/** Error properties whose binding-member replacement would destroy the promised Error contract. */ +const RESERVED_ERROR_PROPERTIES = new Set(['name', 'message', 'stack']) + /** * The shell a program is wrapped in for the type-strip, matching the * grammatical context it will execute in (an async function body, where @@ -109,6 +115,26 @@ function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error) } +/** Resolve after a worker pipe emits all queued data, or closes/errors during termination. */ +function waitForPipeDrain(stream: Readable): Promise { + if (stream.readableEnded || stream.destroyed) return Promise.resolve() + return new Promise((resolve) => { + const done = (): void => { + stream.off('end', done) + stream.off('close', done) + stream.off('error', done) + resolve() + } + stream.once('end', done) + stream.once('close', done) + stream.once('error', done) + // Close the event-registration race if termination finished between the + // initial state check and the listeners above. + /* v8 ignore next -- this race cannot be scheduled deterministically between the adjacent state check and listener registration. */ + if (stream.readableEnded || stream.destroyed) done() + }) +} + /** * Runtime shape gate for inbound port traffic. The peer runs MODEL CODE and * can post anything — `null`, primitives, objects with poisoned fields — so @@ -124,31 +150,88 @@ function parseWorkerMessage(raw: unknown): WorkerToHost | undefined { switch (m.type) { case 'call': { if (typeof m.id !== 'number' || typeof m.global !== 'string' || typeof m.name !== 'string') return undefined - return { type: 'call', id: m.id, global: m.global, name: m.name, args: m.args } + return { type: 'call', id: m.id, global: m.global, name: m.name, args: m.args as WorkerJsonWire } } case 'log': { if (typeof m.text !== 'string') return undefined return { type: 'log', text: m.text } } + case 'output-limit': return { type: 'output-limit' } case 'done': { - if (m.error === undefined) return { type: 'done', ...m.value !== undefined ? { value: m.value } : {} } + if (m.error === undefined) return { type: 'done', ...m.value !== undefined ? { value: m.value as WorkerJsonWire } : {} } const error = m.error if (typeof error !== 'object' || error === null) return undefined - const message = (error as Record).message - if (typeof message !== 'string') return undefined - return { type: 'done', ...m.value !== undefined ? { value: m.value } : {}, error: { message } } + const { kind, message } = error as Record + if ((kind !== 'exception' && kind !== 'invalid-output' && kind !== 'output-limit') || typeof message !== 'string') return undefined + return { type: 'done', error: { kind, message } } } default: return undefined } } -/** - * Headroom the host's value re-cap grants over `maxValueBytes`: exactly the - * truncation suffix {@link prepareValue} appends, so a value the WORKER - * already capped (byte-exact prefix + this marker) passes through unchanged - * instead of being marked twice. - */ -const VALUE_RENDER_SLACK = Buffer.byteLength('… [truncated]', 'utf8') + +/** One run's combined outer-output ledger; binding values never enter it. */ +class OutputLedger { + private bytes = 2 // JSON serialization of the empty logs array: [] + private entries = 0 + + constructor(private readonly maxBytes: number) {} + + /** Admit one exact log entry, or report that the hard cap was crossed. */ + admit(text: string, sink: string[]): boolean { + const separatorBytes = this.entries > 0 ? 1 : 0 + const stringBytes = jsonStringBytesUpTo(text, this.maxBytes - this.bytes - separatorBytes) + if (stringBytes === undefined) return false + this.bytes += stringBytes + separatorBytes + this.entries += 1 + sink.push(text) + return true + } + + /** Finalize a successful absent-or-JSON completion against the combined cap. */ + success(logs: string[], value?: CodeJsonValue): CodeRunResult { + if (value !== undefined && jsonValueBytesUpTo(value, this.maxBytes - this.bytes) === undefined) return this.limit(logs) + return { logs, ...value !== undefined ? { value } : {} } + } + + /** Finalize a failure diagnostic, with output-limit taking precedence when combined bytes exceed the cap. */ + failure(logs: string[], error: CodeRunFailure): CodeRunResult { + if (jsonStringBytesUpTo(error.message, this.maxBytes - this.bytes) === undefined) return this.limit(logs) + return { logs, error } + } + + /** Build the explicit output-limit failure while retaining a fitting prefix of the final log. */ + limit(logs: string[]): CodeRunResult { + const fullMessage = `outer output exceeded ${this.maxBytes} bytes` + // The fixed diagnostic is ASCII, so every character is one byte plus the quotes. + const messageBytes = fullMessage.length + 2 + const retained: string[] = [] + let retainedBytes = 2 + const logBudget = this.maxBytes - messageBytes + for (const text of logs) { + const separatorBytes = retained.length > 0 ? 1 : 0 + const availableBytes = logBudget - retainedBytes - separatorBytes + const stringBytes = jsonStringBytesUpTo(text, availableBytes) + if (stringBytes !== undefined) { + retained.push(text) + retainedBytes += stringBytes + separatorBytes + continue + } + const prefix = truncateJsonStringBytes(text, availableBytes) + if (prefix.length > 0) { + const prefixBytes = jsonStringBytesUpTo(prefix, availableBytes) + /* v8 ignore next -- truncateJsonStringBytes guarantees its returned prefix fits the same budget. */ + if (prefixBytes === undefined) throw new Error('output ledger produced an oversized log prefix') + retained.push(prefix) + retainedBytes += prefixBytes + separatorBytes + } + break + } + const availableMessageBytes = this.maxBytes - retainedBytes + const message = truncateJsonStringBytes(fullMessage, availableMessageBytes) + return { logs: retained, error: { kind: 'output-limit', message } } + } +} /** * The shipped {@link CodeRuntime} backend (`ctx.codeRuntime`). Registers as @@ -161,8 +244,7 @@ export class WorkerCodeRuntime extends CodeRuntime { static Config: z = z.object({ computeMs: z.number().default(60_000), maxWallMs: z.number().default(600_000), - maxLogBytes: z.number().default(65_536), - maxValueBytes: z.number().default(32_768), + maxOutputBytes: z.number().default(67_108_864), maxOldGenerationSizeMb: z.number().default(512), }) @@ -181,6 +263,9 @@ export class WorkerCodeRuntime extends CodeRuntime { for (const [key, value] of Object.entries(this.config)) { if (!(Number.isFinite(value) && value > 0)) throw new Error(`dsh-code-runtime-worker: config.${key} must be a positive number, got ${String(value)}`) } + if (!Number.isSafeInteger(this.config.maxOutputBytes) || this.config.maxOutputBytes < MIN_OUTPUT_BYTES) { + throw new Error(`dsh-code-runtime-worker: config.maxOutputBytes must be a safe integer of at least ${MIN_OUTPUT_BYTES}, got ${String(this.config.maxOutputBytes)}`) + } ctx.effect(() => () => this.teardown(), 'worker code-runtime teardown') } @@ -208,7 +293,7 @@ export class WorkerCodeRuntime extends CodeRuntime { if (this.disposed) throw new Error('dsh-code-runtime-worker: run() after disposal') const bindings = this.validateBindings(request) if (request.signal?.aborted) { - return { logs: [], error: { kind: 'abort', message: String(request.signal.reason) } } + return this.failureBeforeWorker({ kind: 'abort', message: String(request.signal.reason) }) } let code: string @@ -219,15 +304,20 @@ export class WorkerCodeRuntime extends CodeRuntime { // A program that does not survive the type-strip (syntax error, // non-erasable syntax like `enum`) is a program failure, reported the // same way a thrown exception would be — and no worker ever spawns. - return { logs: [], error: { kind: 'exception', message: messageOf(error) } } + return this.failureBeforeWorker({ kind: 'exception', message: messageOf(error) }) } return await this.execute(request, code, bindings) } - /** Reject (seam misuse) malformed binding namespaces: non-identifier or reserved globals, duplicates, and the `console` collision. */ - private validateBindings(request: CodeRunRequest): Map> { - const bindings = new Map>() + /** Apply the outer-output ledger to failures that occur before a worker owns one. */ + private failureBeforeWorker(error: CodeRunFailure): CodeRunResult { + return new OutputLedger(this.config.maxOutputBytes).failure([], error) + } + + /** Reject malformed binding globals or typed-error declarations as seam misuse. */ + private validateBindings(request: CodeRunRequest): Map { + const bindings = new Map() for (const namespace of request.bindings) { if (!IDENTIFIER.test(namespace.global) || RESERVED_WORDS.has(namespace.global)) { throw new Error(`dsh-code-runtime-worker: binding global ${JSON.stringify(namespace.global)} is not a usable identifier`) @@ -235,7 +325,23 @@ export class WorkerCodeRuntime extends CodeRuntime { if (namespace.global === 'console' || bindings.has(namespace.global)) { throw new Error(`dsh-code-runtime-worker: duplicate binding global ${JSON.stringify(namespace.global)}`) } - bindings.set(namespace.global, namespace.functions) + bindings.set(namespace.global, namespace) + } + + const errorClassNames = new Set() + for (const namespace of request.bindings) { + const descriptor = namespace.errorClass + if (!descriptor) continue + if (!IDENTIFIER.test(descriptor.name) || RESERVED_WORDS.has(descriptor.name)) { + throw new Error(`dsh-code-runtime-worker: binding error class ${JSON.stringify(descriptor.name)} is not a usable identifier`) + } + if (descriptor.name === 'console' || bindings.has(descriptor.name) || errorClassNames.has(descriptor.name)) { + throw new Error(`dsh-code-runtime-worker: duplicate injected global ${JSON.stringify(descriptor.name)}`) + } + if (descriptor.memberNameProperty.length === 0 || RESERVED_ERROR_PROPERTIES.has(descriptor.memberNameProperty)) { + throw new Error(`dsh-code-runtime-worker: binding error member property ${JSON.stringify(descriptor.memberNameProperty)} is not usable`) + } + errorClassNames.add(descriptor.name) } return bindings } @@ -244,13 +350,16 @@ export class WorkerCodeRuntime extends CodeRuntime { private execute( request: CodeRunRequest, code: string, - bindings: Map>, + bindings: Map, ): Promise { const bootData: WorkerBootData = { code, - namespaces: [...bindings].map(([global, functions]) => ({ global, names: Object.keys(functions) })), - maxLogBytes: this.config.maxLogBytes, - maxValueBytes: this.config.maxValueBytes, + namespaces: [...bindings].map(([global, namespace]) => ({ + global, + names: Object.keys(namespace.functions), + ...namespace.errorClass ? { errorClass: namespace.errorClass } : {}, + })), + maxOutputBytes: this.config.maxOutputBytes, } const worker = new Worker(WORKER_PATH, { workerData: bootData, @@ -274,28 +383,22 @@ export class WorkerCodeRuntime extends CodeRuntime { const answered = new Set() const logs: string[] = [] const strayLogs: string[] = [] + const output = new OutputLedger(this.config.maxOutputBytes) + let terminalOverride: CodeRunResult | undefined - // One host-side budget covers normal, forged, and stray-pipe log entries. The first - // overflow emits the shared in-band marker and drops everything after it. - let logBudget = this.config.maxLogBytes - let logsTruncated = false - const admit = (text: string, sink: string[]): void => { - if (logsTruncated) return - const cost = Buffer.byteLength(text, 'utf8') - if (cost > logBudget) { - logsTruncated = true - sink.push(logTruncationMarker(this.config.maxLogBytes)) - return - } - logBudget -= cost - sink.push(text) - } - - // No settled guard: `finish` snapshots the arrays when it resolves, so - // a chunk flushing after settlement mutates only the discarded buffers, - // and the ledger bounds that growth until the pipes close. + // Pipe and message-port delivery are independent. Continue bounded pipe + // capture after a terminal message while worker termination drains bytes + // that were already queued; `finish` materializes the result only after + // termination completes. const captureStray = (chunk: Buffer): void => { - admit(chunk.toString('utf8'), strayLogs) + /* v8 ignore next -- a second post-overflow chunk races immediate worker termination; the first overflow path is covered. */ + if (terminalOverride !== undefined) return + const text = chunk.toString('utf8') + if (!output.admit(text, strayLogs)) { + const limited = output.limit([...logs, ...strayLogs, text]) + terminalOverride = limited + finish(limited) + } } worker.stdout.on('data', captureStray) worker.stderr.on('data', captureStray) @@ -304,27 +407,42 @@ export class WorkerCodeRuntime extends CodeRuntime { // logs captured before timeout, abort, or failure remain in the result. let finishResolve!: () => void const finished = new Promise((done) => { finishResolve = done }) - const finish = (result: Omit): void => { + const finish = (finalize: CodeRunResult | (() => CodeRunResult)): void => { if (settled) return settled = true clearInterval(eluTimer) clearTimeout(wallTimer) request.signal?.removeEventListener('abort', onAbort) this.live.delete(live) - void worker.terminate().then(() => { + // Let the poll phase deliver pipe bytes already queued independently + // of the terminal port message before termination closes the streams. + void new Promise((resume) => { setImmediate(resume) }).then(async () => { + const stdoutDrained = waitForPipeDrain(worker.stdout) + const stderrDrained = waitForPipeDrain(worker.stderr) + await Promise.all([worker.terminate(), stdoutDrained, stderrDrained]) + const result = terminalOverride ?? (typeof finalize === 'function' ? finalize() : finalize) finishResolve() - resolve({ ...result, logs: [...logs, ...strayLogs] }) + resolve(result) }) } const onDone = (message: WorkerToHost): void => { if (message.type !== 'done') return - // Re-cap forged completion traffic at the hostile boundary. Honest worker-capped values - // pass unchanged via VALUE_RENDER_SLACK; error text is bounded too. - finish({ - ...prepareValue(message.value, this.config.maxValueBytes + VALUE_RENDER_SLACK), - ...message.error ? { error: { kind: 'exception' as const, message: truncateUtf8Bytes(message.error.message, this.config.maxValueBytes) } } : {}, - }) + if (message.error) { + const error = message.error + finish(() => output.failure([...logs, ...strayLogs], error)) + return + } + if (message.value === undefined) { + finish(() => output.success([...logs, ...strayLogs])) + return + } + const value = decodeWorkerJson(message.value) + if (value === undefined) { + finish(() => output.failure([...logs, ...strayLogs], { kind: 'invalid-output', message: 'program completion must be lossless JSON' })) + } else { + finish(() => output.success([...logs, ...strayLogs], value)) + } } const onCall = (message: WorkerToHost): void => { @@ -336,15 +454,11 @@ export class WorkerCodeRuntime extends CodeRuntime { answered.add(message.id) const reply = (payload: ReplyMessage): void => { if (settled) return - try { - worker.postMessage(payload) - } catch { - // The reply value failed structured clone; renegotiate as an error - // reply, which is always clone-plain. Nothing else throws here. - worker.postMessage({ type: 'reply', id: message.id, ok: false, message: 'binding resolution is not structured-cloneable' }) - } + // Canonical resolutions were snapshotted as lossless JSON before + // this point, so this payload is structured-cloneable by contract. + worker.postMessage(payload) } - const record = bindings.get(message.global) + const record = bindings.get(message.global)?.functions // Own-property lookup only: a forged name like 'constructor' or // 'hasOwnProperty' must not walk the record's prototype chain and // reach a callable the consumer never declared. @@ -353,9 +467,25 @@ export class WorkerCodeRuntime extends CodeRuntime { reply({ type: 'reply', id: message.id, ok: false, message: `unknown binding ${JSON.stringify(`${message.global}.${message.name}`)}` }) return } + const args = decodeWorkerJson(message.args) + if (args === undefined) { + reply({ type: 'reply', id: message.id, ok: false, message: 'binding arguments must be lossless JSON' }) + return + } void (async () => { try { - reply({ type: 'reply', id: message.id, ok: true, value: await fn(message.args) }) + const resolved = await fn(args) + let value: CodeJsonValue | undefined + try { + value = snapshotJsonValue(resolved) + } catch { + value = undefined + } + if (value === undefined) { + reply({ type: 'reply', id: message.id, ok: false, message: 'binding resolution must be lossless JSON' }) + } else { + reply({ type: 'reply', id: message.id, ok: true, value: encodeWorkerJson(value) }) + } } catch (error: unknown) { reply({ type: 'reply', id: message.id, ok: false, message: messageOf(error) }) } @@ -367,15 +497,24 @@ export class WorkerCodeRuntime extends CodeRuntime { // this listener would crash the host process. Junk drops silently. const message = parseWorkerMessage(raw) if (!message) return - if (message.type === 'log' && !settled) admit(message.text, logs) + if (message.type === 'log' && !settled && !output.admit(message.text, logs)) { + const limited = output.limit([...logs, ...strayLogs, message.text]) + finish(limited) + return + } + if (message.type === 'output-limit' && !settled) { + const limited = output.limit([...logs, ...strayLogs]) + finish(limited) + return + } onCall(message) onDone(message) }) worker.on('error', (error: Error) => { - finish({ error: { kind: 'worker-exit', message: `worker error: ${error.message}` } }) + finish(() => output.failure([...logs, ...strayLogs], { kind: 'worker-exit', message: `worker error: ${error.message}` })) }) worker.on('exit', (exitCode: number) => { - finish({ error: { kind: 'worker-exit', message: `worker exited with code ${exitCode} before completing` } }) + finish(() => output.failure([...logs, ...strayLogs], { kind: 'worker-exit', message: `worker exited with code ${exitCode} before completing` })) }) // The compute budget reads the worker's own measured busy time, so a @@ -384,21 +523,21 @@ export class WorkerCodeRuntime extends CodeRuntime { const eluTimer = setInterval(() => { const elu = worker.performance.eventLoopUtilization() if (elu.active > this.config.computeMs) { - finish({ error: { kind: 'timeout', message: `compute budget exhausted (${this.config.computeMs}ms busy)` } }) + finish(() => output.failure([...logs, ...strayLogs], { kind: 'timeout', message: `compute budget exhausted (${this.config.computeMs}ms busy)` })) } }, ELU_POLL_INTERVAL_MS) const wallTimer = setTimeout(() => { - finish({ error: { kind: 'timeout', message: `wall-clock ceiling reached (${this.config.maxWallMs}ms)` } }) + finish(() => output.failure([...logs, ...strayLogs], { kind: 'timeout', message: `wall-clock ceiling reached (${this.config.maxWallMs}ms)` })) }, this.config.maxWallMs) const onAbort = (): void => { - finish({ error: { kind: 'abort', message: String(request.signal?.reason) } }) + finish(() => output.failure([...logs, ...strayLogs], { kind: 'abort', message: String(request.signal?.reason) })) } request.signal?.addEventListener('abort', onAbort, { once: true }) const live: LiveRun = { worker, finished, - settle: (failure: CodeRunFailure) => { finish({ error: failure }) }, + settle: (failure: CodeRunFailure) => { finish(() => output.failure([...logs, ...strayLogs], failure)) }, } this.live.add(live) }) diff --git a/packages/code-runtime/code-runtime-worker/src/output-json.ts b/packages/code-runtime/code-runtime-worker/src/output-json.ts new file mode 100644 index 0000000000..06d56292bf --- /dev/null +++ b/packages/code-runtime/code-runtime-worker/src/output-json.ts @@ -0,0 +1,179 @@ +/** JSON string-prefix accounting for the outer-output ledger. @module @deepseek-ai/dsh-code-runtime-worker/output-json */ + +import type { CodeJsonValue } from '@deepseek-ai/dsh-code-runtime' + +type IntrinsicCallable = (this: unknown, ...args: unknown[]) => unknown + +const intrinsicReflectApply = Reflect.apply as ( + target: IntrinsicCallable, + thisArgument: unknown, + argumentsList: readonly unknown[], +) => unknown +const intrinsicArrayIsArray = Array.isArray +const IntrinsicBuffer = Buffer +const intrinsicBufferByteLength = Reflect.get(Buffer, 'byteLength') as IntrinsicCallable +const intrinsicObjectCreate = Object.create +const intrinsicObjectDefineProperty = Object.defineProperty +const intrinsicObjectKeys = Object.keys +const intrinsicString = String +const intrinsicStringCharCodeAt = Reflect.get(String.prototype, 'charCodeAt') as IntrinsicCallable +const intrinsicStringCodePointAt = Reflect.get(String.prototype, 'codePointAt') as IntrinsicCallable +const intrinsicStringSlice = Reflect.get(String.prototype, 'slice') as IntrinsicCallable + +/** Build a data descriptor that cannot inherit model-defined accessor fields. */ +function dataDescriptor(value: unknown): PropertyDescriptor { + const descriptor = intrinsicObjectCreate(null) as PropertyDescriptor + descriptor.value = value + return descriptor +} + +/** Define an ordinary enumerable data slot without a prototype-bearing descriptor. */ +function defineEnumerableDataProperty(target: object, key: PropertyKey, value: unknown): void { + const descriptor = dataDescriptor(value) + descriptor.enumerable = true + descriptor.configurable = true + descriptor.writable = true + intrinsicObjectDefineProperty(target, key, descriptor) +} + +/** UTF-8 byte length through the module-captured Node intrinsic. */ +function byteLength(text: string): number { + return intrinsicReflectApply(intrinsicBufferByteLength, IntrinsicBuffer, [text, 'utf8']) as number +} + +/** Append without consulting a model-mutated `Array.prototype`. */ +function append(target: T[], value: T): void { + defineEnumerableDataProperty(target, target.length, value) +} + +/** Pop without consulting a model-mutated `Array.prototype`. */ +function takeLast(target: T[]): T | undefined { + if (target.length === 0) return undefined + const index = target.length - 1 + const value = target[index] + intrinsicObjectDefineProperty(target, 'length', dataDescriptor(index)) + return value +} + +/** One code-point-aligned character from a string. */ +function characterAt(text: string, index: number): string { + const codePoint = intrinsicReflectApply(intrinsicStringCodePointAt, text, [index]) as number + const width = codePoint > 0xffff ? 2 : 1 + return intrinsicReflectApply(intrinsicStringSlice, text, [index, index + width]) as string +} + +/** Serialized bytes contributed by one complete Unicode code point inside JSON quotes. */ +function serializedCharacterBytes(character: string): number { + if (character.length === 2) return 4 + if (character === '"' || character === '\\') return 2 + const code = intrinsicReflectApply(intrinsicStringCharCodeAt, character, [0]) as number + if (code >= 0xd800 && code <= 0xdfff) return 6 + if (code < 0x20) return code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d ? 2 : 6 + return byteLength(character) +} + +/** + * Measure one JSON string without materializing its complete escaped form. + * @param text - the candidate string. + * @param maxBytes - largest serialized size the caller can admit. + * @returns Exact serialized bytes, or `undefined` as soon as the cap is crossed. + */ +export function jsonStringBytesUpTo(text: string, maxBytes: number): number | undefined { + if (maxBytes < 2) return undefined + let bytes = 2 + for (let index = 0; index < text.length;) { + const character = characterAt(text, index) + bytes += serializedCharacterBytes(character) + if (bytes > maxBytes) return undefined + index += character.length + } + return bytes +} + +/** + * Measure one lossless JSON value without allocating its serialized form. + * @param value - already validated lossless JSON. + * @param maxBytes - largest serialized size the caller can admit. + * @returns Exact serialized bytes, or `undefined` as soon as the cap is crossed. + */ +export function jsonValueBytesUpTo(value: CodeJsonValue, maxBytes: number): number | undefined { + type Task = + | { kind: 'value'; value: CodeJsonValue } + | { kind: 'array'; value: CodeJsonValue[]; index: number } + | { kind: 'object'; value: Record; keys: string[]; index: number } + + let bytes = 0 + const add = (cost: number): boolean => { + bytes += cost + return bytes <= maxBytes + } + const tasks: Task[] = [{ kind: 'value', value }] + for (let task = takeLast(tasks); task !== undefined; task = takeLast(tasks)) { + if (task.kind === 'value') { + const current = task.value + if (current === null) { + if (!add(4)) return undefined + } else if (typeof current === 'string') { + const stringBytes = jsonStringBytesUpTo(current, maxBytes - bytes) + if (stringBytes === undefined) return undefined + bytes += stringBytes + } else if (typeof current === 'number') { + if (!add(byteLength(intrinsicString(current)))) return undefined + } else if (typeof current === 'boolean') { + if (!add(current ? 4 : 5)) return undefined + } else if (intrinsicArrayIsArray(current)) { + if (!add(2)) return undefined + if (current.length > 0) append(tasks, { kind: 'array', value: current, index: 0 }) + } else { + if (!add(2)) return undefined + const keys = intrinsicObjectKeys(current) + if (keys.length > 0) append(tasks, { kind: 'object', value: current, keys, index: 0 }) + } + continue + } + + if (task.index > 0 && !add(1)) return undefined + if (task.kind === 'array') { + const item = task.value[task.index] + if (item === undefined) return undefined + if (task.index + 1 < task.value.length) append(tasks, { ...task, index: task.index + 1 }) + append(tasks, { kind: 'value', value: item }) + continue + } + + const key = task.keys[task.index] + /* v8 ignore next -- an object frame is created and advanced only for an existing Object.keys entry. */ + if (key === undefined) return undefined + const keyBytes = jsonStringBytesUpTo(key, maxBytes - bytes) + if (keyBytes === undefined) return undefined + if (!add(keyBytes + 1)) return undefined + const item = task.value[key] + if (item === undefined) return undefined + if (task.index + 1 < task.keys.length) append(tasks, { ...task, index: task.index + 1 }) + append(tasks, { kind: 'value', value: item }) + } + return bytes +} + +/** + * Return the longest code-point-aligned prefix whose JSON string encoding, + * including its surrounding quotes, fits `maxBytes`. + * + * @param text - the candidate string. + * @param maxBytes - serialized JSON-string bytes available. + * @returns the fitting prefix, or an empty string when even useful content cannot fit. + */ +export function truncateJsonStringBytes(text: string, maxBytes: number): string { + if (maxBytes < 2) return '' + let bytes = 2 + let end = 0 + for (let index = 0; index < text.length;) { + const character = characterAt(text, index) + const cost = serializedCharacterBytes(character) + if (bytes + cost > maxBytes) break + bytes += cost + end += character.length + index += character.length + } + return end === text.length ? text : intrinsicReflectApply(intrinsicStringSlice, text, [0, end]) as string +} diff --git a/packages/code-runtime/code-runtime-worker/src/protocol.ts b/packages/code-runtime/code-runtime-worker/src/protocol.ts index 1ce108b7cc..11559d23b7 100644 --- a/packages/code-runtime/code-runtime-worker/src/protocol.ts +++ b/packages/code-runtime/code-runtime-worker/src/protocol.ts @@ -5,16 +5,20 @@ * @module @deepseek-ai/dsh-code-runtime-worker/src/protocol */ +import type { WorkerJsonWire } from './worker-json.ts' + /** What the host hands the worker at spawn, via `workerData`. */ export interface WorkerBootData { /** The type-stripped (plain JS) program body. */ code: string - /** Binding namespaces to materialize: the global name plus the function names (functions themselves stay host-side). */ - namespaces: { global: string; names: string[] }[] - /** Shared byte budget for captured log text; exceeding it drops further entries after one in-band marker. */ - maxLogBytes: number - /** Byte cap for the rendered completion value (see the value-preparation contract in bootstrap.ts). */ - maxValueBytes: number + /** Binding namespaces to materialize; functions themselves stay host-side. */ + namespaces: { + global: string + names: string[] + errorClass?: { name: string; memberNameProperty: string } + }[] + /** Hard cap for the combined serialized outer logs plus completion value or failure diagnostic. */ + maxOutputBytes: number } /** Worker → host: one bridged binding call. */ @@ -26,8 +30,8 @@ interface CallMessage { global: string /** The function name within the namespace. */ name: string - /** The single argument, structured-clone-plain. */ - args: unknown + /** The single argument as a flat lossless-JSON wire value. */ + args: WorkerJsonWire } /** Worker → host: captured text, streamed eagerly so output survives a mid-run termination (timeout, abort, OOM). */ @@ -36,37 +40,29 @@ interface LogMessage { text: string } +/** Worker → host: worker-side capture or completion measurement exceeded the outer cap. */ +interface OutputLimitMessage { + type: 'output-limit' +} + /** - * Worker → host: the program settled. `error` carries a program exception - * (the only failure the bootstrap itself can report — budgets, aborts, and - * substrate death are observed host-side). `value` is present only on a - * clean completion that produced one (already size-capped and - * clone-safe per the bootstrap's value preparation). Logs are NOT carried - * here — they streamed eagerly as {@link LogMessage}s. + * Worker → host: the program settled. `error` carries a program exception, + * invalid completion, or output overflow (budgets, aborts, and substrate death + * are observed host-side). `value` is present only on a clean completion that + * produced one, as a flat wire value already lossless and admitted against + * the remaining combined output cap. Logs are NOT carried here — they streamed + * eagerly as {@link LogMessage}s. */ export interface DoneMessage { type: 'done' - value?: unknown - error?: { message: string } + value?: WorkerJsonWire + error?: { kind: 'exception' | 'invalid-output' | 'output-limit'; message: string } } /** Every message the worker sends. */ -export type WorkerToHost = CallMessage | LogMessage | DoneMessage +export type WorkerToHost = CallMessage | LogMessage | OutputLimitMessage | DoneMessage /** Host → worker: the answer to one {@link CallMessage}. */ export type ReplyMessage = - | { type: 'reply'; id: number; ok: true; value: unknown } + | { type: 'reply'; id: number; ok: true; value: WorkerJsonWire } | { type: 'reply'; id: number; ok: false; message: string } - -/** - * The in-band marker entry text announcing that log capture stopped at the - * byte budget. Shared wire vocabulary: the worker's LogBuffer emits it when - * ITS budget exhausts, and the host emits the identical text when its own - * ledger drops an entry first (forged port traffic, stray pipe bytes) — so - * a truncated run reads the same however the cap was hit. - * @param maxBytes - the configured `maxLogBytes` the marker names. - * @returns the marker line. - */ -export function logTruncationMarker(maxBytes: number): string { - return `[dsh-code-runtime-worker] log capture truncated at ${maxBytes} bytes` -} diff --git a/packages/code-runtime/code-runtime-worker/src/worker-json.ts b/packages/code-runtime/code-runtime-worker/src/worker-json.ts new file mode 100644 index 0000000000..b91005bb68 --- /dev/null +++ b/packages/code-runtime/code-runtime-worker/src/worker-json.ts @@ -0,0 +1,417 @@ +/** Lossless-JSON snapshots for the dependency-free source worker closure. @module @deepseek-ai/dsh-code-runtime-worker/worker-json */ + +import type { CodeJsonValue } from '@deepseek-ai/dsh-code-runtime' + +/* jscpd:ignore-start -- the source worker mirrors session JSON helpers without workspace runtime imports */ +type IntrinsicCallable = (this: unknown, ...args: unknown[]) => unknown + +const intrinsicFunctionToString = Reflect.get(Function.prototype, 'toString') as IntrinsicCallable +const intrinsicReflectApply = Reflect.get(Reflect, 'apply') as ( + target: IntrinsicCallable, + thisArgument: unknown, + argumentsList: readonly unknown[], +) => unknown +const IntrinsicError = Error +const IntrinsicSet = Set +const intrinsicArrayIsArray = Array.isArray +const intrinsicArrayPrototype = Array.prototype +const intrinsicNumberIsFinite = Number.isFinite +const intrinsicNumberIsSafeInteger = Number.isSafeInteger +const intrinsicObjectCreate = Object.create +const intrinsicObjectDefineProperty = Object.defineProperty +const intrinsicObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor +const intrinsicObjectGetPrototypeOf = Object.getPrototypeOf +const intrinsicObjectHasOwn = Object.hasOwn +const intrinsicObjectIs = Object.is +const intrinsicObjectKeys = Object.keys +const intrinsicObjectPrototype = Object.prototype +const intrinsicObjectPropertyIsEnumerable = Reflect.get(intrinsicObjectPrototype, 'propertyIsEnumerable') as IntrinsicCallable +const intrinsicReflectOwnKeys = Reflect.ownKeys +const intrinsicSetAdd = Reflect.get(Set.prototype, 'add') as IntrinsicCallable +const intrinsicSetDelete = Reflect.get(Set.prototype, 'delete') as IntrinsicCallable +const intrinsicSetHas = Reflect.get(Set.prototype, 'has') as IntrinsicCallable + +/** Build a data descriptor that cannot inherit model-defined accessor fields. */ +function dataDescriptor(value: unknown): PropertyDescriptor { + const descriptor = intrinsicObjectCreate(null) as PropertyDescriptor + descriptor.value = value + return descriptor +} + +/** Define an ordinary enumerable data slot without a prototype-bearing descriptor. */ +function defineEnumerableDataProperty(target: object, key: PropertyKey, value: unknown): void { + const descriptor = dataDescriptor(value) + descriptor.enumerable = true + descriptor.configurable = true + descriptor.writable = true + intrinsicObjectDefineProperty(target, key, descriptor) +} + +/** Append without consulting a model-mutated `Array.prototype`. */ +function append(target: T[], value: T): void { + defineEnumerableDataProperty(target, target.length, value) +} + +/** Pop without consulting a model-mutated `Array.prototype`. */ +function takeLast(target: T[]): T | undefined { + if (target.length === 0) return undefined + const index = target.length - 1 + const value = target[index] + intrinsicObjectDefineProperty(target, 'length', dataDescriptor(index)) + return value +} + +/** Whether one captured-intrinsic Set contains a value. */ +function setHas(target: Set, value: T): boolean { + return intrinsicReflectApply(intrinsicSetHas, target, [value]) as boolean +} + +/** Add to one captured-intrinsic Set. */ +function setAdd(target: Set, value: T): void { + intrinsicReflectApply(intrinsicSetAdd, target, [value]) +} + +/** Delete from one captured-intrinsic Set. */ +function setDelete(target: Set, value: T): void { + intrinsicReflectApply(intrinsicSetDelete, target, [value]) +} + +/** Whether a realm-owned intrinsic prototype is backed by its native constructor. */ +function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): boolean { + const descriptor = intrinsicObjectGetOwnPropertyDescriptor(prototype, 'constructor') + const constructor: unknown = descriptor?.value + if (typeof constructor !== 'function') return false + try { + return constructor.name === name + && constructor.prototype === prototype + && intrinsicReflectApply(intrinsicFunctionToString, constructor, []) === `function ${name}() { [native code] }` + } catch { + return false + } +} + +/** Whether a candidate is a foreign realm's intrinsic `Object.prototype`. */ +function isForeignIntrinsicObjectPrototype(value: object): boolean { + return intrinsicObjectGetPrototypeOf(value) === null && hasIntrinsicConstructor(value, 'Object') +} + +/** Whether an array uses one realm's intrinsic `Array.prototype`, not a subclass or forged prototype. */ +function hasPlainArrayPrototype(value: unknown[]): boolean { + const prototype: unknown = intrinsicObjectGetPrototypeOf(value) + if (prototype === intrinsicArrayPrototype) return true + if (!intrinsicArrayIsArray(prototype) || !hasIntrinsicConstructor(prototype, 'Array')) return false + const objectPrototype: unknown = intrinsicObjectGetPrototypeOf(prototype) + return typeof objectPrototype === 'object' + && objectPrototype !== null + && isForeignIntrinsicObjectPrototype(objectPrototype) +} + +/** Whether an object is a plain or null-prototype record from any JavaScript realm. */ +function hasPlainObjectPrototype(value: object): boolean { + const prototype: unknown = intrinsicObjectGetPrototypeOf(value) + return prototype === null + || prototype === intrinsicObjectPrototype + || typeof prototype === 'object' && isForeignIntrinsicObjectPrototype(prototype) +} + +/** Return every JSON-visible object key, or reject own data JSON would discard. */ +function enumerableStringKeys(value: object): string[] | undefined { + const keys = intrinsicReflectOwnKeys(value) + for (let index = 0; index < keys.length; index++) { + const key = keys[index] + if (typeof key !== 'string' || !intrinsicReflectApply(intrinsicObjectPropertyIsEnumerable, value, [key])) return undefined + } + return keys as string[] +} + +type SnapshotDestination = + | { kind: 'root' } + | { kind: 'array'; target: CodeJsonValue[]; index: number } + | { kind: 'object'; target: Record; key: string } + +type SnapshotTask = + | { kind: 'visit'; value: unknown; destination: SnapshotDestination } + | { kind: 'array-item'; source: unknown[]; index: number; target: CodeJsonValue[] } + | { kind: 'object-property'; source: Record; key: string; target: Record } + | { kind: 'leave'; source: object } + +/** + * Validate and detach one worker-boundary value without loading another + * workspace package at runtime. This mirrors the session-owned canonical + * JSON boundary while remaining safe to import from the unbuilt worker. + * Its iterative traversal adds no JavaScript call-stack depth limit. + * + * @param value - the candidate completion value. + * @returns a detached lossless-JSON snapshot, or `undefined` when invalid. + */ +export function snapshotCodeJsonValue(value: unknown): CodeJsonValue | undefined { + const active = new IntrinsicSet() + let root: CodeJsonValue | undefined + const assign = (destination: SnapshotDestination, item: CodeJsonValue): void => { + if (destination.kind === 'root') { + root = item + } else if (destination.kind === 'array') { + defineEnumerableDataProperty(destination.target, destination.index, item) + } else { + defineEnumerableDataProperty(destination.target, destination.key, item) + } + } + + const tasks: SnapshotTask[] = [{ kind: 'visit', value, destination: { kind: 'root' } }] + for (let task = takeLast(tasks); task !== undefined; task = takeLast(tasks)) { + if (task.kind === 'leave') { + setDelete(active, task.source) + continue + } + if (task.kind === 'array-item') { + if (!intrinsicObjectHasOwn(task.source, task.index)) return undefined + append(tasks, { + kind: 'visit', + value: task.source[task.index], + destination: { kind: 'array', target: task.target, index: task.index }, + }) + continue + } + if (task.kind === 'object-property') { + append(tasks, { + kind: 'visit', + value: task.source[task.key], + destination: { kind: 'object', target: task.target, key: task.key }, + }) + continue + } + + const candidate = task.value + if (candidate === null) { + assign(task.destination, null) + continue + } + if (typeof candidate === 'boolean' || typeof candidate === 'string') { + assign(task.destination, candidate) + continue + } + if (typeof candidate === 'number') { + if (!intrinsicNumberIsFinite(candidate) || intrinsicObjectIs(candidate, -0)) return undefined + assign(task.destination, candidate) + continue + } + if (typeof candidate !== 'object') return undefined + if (setHas(active, candidate)) return undefined + + if (intrinsicArrayIsArray(candidate)) { + if (!hasPlainArrayPrototype(candidate)) return undefined + const length = candidate.length + if (intrinsicReflectOwnKeys(candidate).length !== length + 1) return undefined + const target: CodeJsonValue[] = [] + assign(task.destination, target) + setAdd(active, candidate) + append(tasks, { kind: 'leave', source: candidate }) + for (let index = length - 1; index >= 0; index--) { + append(tasks, { kind: 'array-item', source: candidate, index, target }) + } + continue + } + + if (!hasPlainObjectPrototype(candidate)) return undefined + const keys = enumerableStringKeys(candidate) + if (keys === undefined) return undefined + const target: Record = {} + assign(task.destination, target) + setAdd(active, candidate) + append(tasks, { kind: 'leave', source: candidate }) + for (let index = keys.length - 1; index >= 0; index--) { + const key = keys[index] + /* v8 ignore next -- the loop is bounded by the captured key count. */ + if (key === undefined) return undefined + append(tasks, { kind: 'object-property', source: candidate as Record, key, target }) + } + } + return root +} + +interface ArrayWireToken { + kind: 'array' + length: number +} + +interface ObjectWireToken { + kind: 'object' + keys: string[] +} + +type WorkerJsonToken = null | boolean | number | string | ArrayWireToken | ObjectWireToken + +/** + * A pre-order, bounded-depth transport for one lossless JSON value. Container + * markers and scalar leaves share one flat token array, so `worker_threads` + * never has to structured-clone the value's application nesting. + */ +export type WorkerJsonWire = WorkerJsonToken[] + +/** + * Flatten one validated JSON value for the worker-thread message port. + * @param value - the lossless JSON value to transport. + * @returns a pre-order token stream whose own nesting is bounded. + */ +export function encodeWorkerJson(value: CodeJsonValue): WorkerJsonWire { + const wire: WorkerJsonWire = [] + const pending: CodeJsonValue[] = [value] + for (let current = takeLast(pending); current !== undefined; current = takeLast(pending)) { + if (current === null || typeof current === 'boolean' || typeof current === 'number' || typeof current === 'string') { + append(wire, current) + continue + } + if (intrinsicArrayIsArray(current)) { + append(wire, { kind: 'array', length: current.length }) + for (let index = current.length - 1; index >= 0; index--) { + const item = current[index] + if (item === undefined) throw new IntrinsicError('cannot encode a sparse JSON array') + append(pending, item) + } + continue + } + const keys = intrinsicObjectKeys(current) + append(wire, { kind: 'object', keys }) + for (let index = keys.length - 1; index >= 0; index--) { + const key = keys[index] + /* v8 ignore next -- the loop is bounded by the captured key count. */ + if (key === undefined) throw new IntrinsicError('cannot encode a missing JSON object key') + const item = current[key] + if (item === undefined) throw new IntrinsicError('cannot encode an undefined JSON object property') + append(pending, item) + } + } + return wire +} + +type DecodeFrame = + | { kind: 'array'; target: CodeJsonValue[]; length: number; index: number } + | { kind: 'object'; target: Record; keys: string[]; index: number } + +/** Whether an array contains exactly its dense indexed slots and `length`. */ +function isDenseArray(value: unknown[]): boolean { + if (!hasPlainArrayPrototype(value) || intrinsicReflectOwnKeys(value).length !== value.length + 1) return false + for (let index = 0; index < value.length; index++) { + if (!intrinsicObjectHasOwn(value, index)) return false + } + return true +} + +/** Whether one exact string-key list contains a key, without consulting its prototype. */ +function keysContain(keys: string[], expected: string): boolean { + for (let index = 0; index < keys.length; index++) { + if (keys[index] === expected) return true + } + return false +} + +/** Return one exact container marker, or reject any extra/missing fields. */ +function containerToken(value: object): ArrayWireToken | ObjectWireToken | undefined { + if (intrinsicArrayIsArray(value) || !hasPlainObjectPrototype(value)) return undefined + const keys = enumerableStringKeys(value) + if (keys === undefined) return undefined + const token = value as Record + if (token.kind === 'array') { + if (keys.length !== 2 || !keysContain(keys, 'kind') || !keysContain(keys, 'length')) return undefined + const length = token.length + return typeof length === 'number' && intrinsicNumberIsSafeInteger(length) && length >= 0 + ? { kind: 'array', length } + : undefined + } + if (token.kind === 'object') { + if (keys.length !== 2 || !keysContain(keys, 'kind') || !keysContain(keys, 'keys')) return undefined + const objectKeys = token.keys + if (!intrinsicArrayIsArray(objectKeys) || !isDenseArray(objectKeys)) return undefined + const unique = new IntrinsicSet() + const normalizedKeys: string[] = [] + const objectKeyValues = objectKeys as unknown[] + for (let index = 0; index < objectKeyValues.length; index++) { + const key = objectKeyValues[index] + if (typeof key !== 'string' || setHas(unique, key)) return undefined + setAdd(unique, key) + append(normalizedKeys, key) + } + return { kind: 'object', keys: normalizedKeys } + } + return undefined +} + +/** + * Rebuild one lossless JSON value from the flat worker-thread wire format. + * Malformed or incomplete traffic returns `undefined`; traversal is iterative + * and therefore independent of the transported value's application depth. + * @param input - untrusted message-port payload. + * @returns the detached JSON value, or `undefined` when the wire is invalid. + */ +export function decodeWorkerJson(input: unknown): CodeJsonValue | undefined { + try { + if (!intrinsicArrayIsArray(input) || !isDenseArray(input) || input.length === 0) return undefined + const wire = input as unknown[] + const frames: DecodeFrame[] = [] + let root: CodeJsonValue | undefined + let rootAssigned = false + + const attach = (value: CodeJsonValue): boolean => { + const parent = frames[frames.length - 1] + if (!parent) { + if (rootAssigned) return false + root = value + rootAssigned = true + return true + } + /* v8 ignore next -- completed frames are popped before another token can attach. */ + if (parent.index >= (parent.kind === 'array' ? parent.length : parent.keys.length)) return false + if (parent.kind === 'array') { + append(parent.target, value) + } else { + const key = parent.keys[parent.index] + /* v8 ignore next -- object frames are built from validated keys and their exact length. */ + if (key === undefined) return false + defineEnumerableDataProperty(parent.target, key, value) + } + parent.index += 1 + return true + } + + for (let tokenIndex = 0; tokenIndex < wire.length; tokenIndex++) { + const token = wire[tokenIndex] + let value: CodeJsonValue + let frame: DecodeFrame | undefined + if (token === null || typeof token === 'boolean' || typeof token === 'string') { + value = token + } else if (typeof token === 'number') { + if (!intrinsicNumberIsFinite(token) || intrinsicObjectIs(token, -0)) return undefined + value = token + } else { + if (typeof token !== 'object') return undefined + const marker = containerToken(token) + if (!marker) return undefined + const remainingTokens = wire.length - tokenIndex - 1 + if (marker.kind === 'array') { + if (marker.length > remainingTokens) return undefined + const target: CodeJsonValue[] = [] + value = target + if (marker.length > 0) frame = { kind: 'array', target, length: marker.length, index: 0 } + } else { + if (marker.keys.length > remainingTokens) return undefined + const target: Record = {} + value = target + if (marker.keys.length > 0) frame = { kind: 'object', target, keys: marker.keys, index: 0 } + } + } + if (!attach(value)) return undefined + if (frame) append(frames, frame) + while (frames.length > 0) { + const current = frames[frames.length - 1] + /* v8 ignore next -- the loop condition guarantees a final frame. */ + if (current === undefined) break + if (current.index < (current.kind === 'array' ? current.length : current.keys.length)) break + takeLast(frames) + } + } + return frames.length === 0 ? root : undefined + } catch { + return undefined + } +} +/* jscpd:ignore-end */ diff --git a/packages/code-runtime/code-runtime-worker/tests/bootstrap.spec.ts b/packages/code-runtime/code-runtime-worker/tests/bootstrap.spec.ts index 111aa4f15f..a2aac6d9c2 100644 --- a/packages/code-runtime/code-runtime-worker/tests/bootstrap.spec.ts +++ b/packages/code-runtime/code-runtime-worker/tests/bootstrap.spec.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from 'vitest' import { EventEmitter } from 'node:events' -import { LogBuffer, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareValue, runWorkerMain, truncateUtf8Bytes, wireReplies } from '../src/bootstrap.ts' +import { LogBuffer, makeBindingErrorClasses, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareCompletion, prepareException, runWorkerMain, wireReplies } from '../src/bootstrap.ts' import type { BootstrapPort, PatchableStream, PendingCall } from '../src/bootstrap.ts' import type { ReplyMessage, WorkerToHost } from '../src/protocol.ts' +import { decodeWorkerJson, encodeWorkerJson } from '../src/worker-json.ts' /** * An in-process stand-in for the worker's parentPort: the test plays the @@ -37,25 +38,52 @@ class FakePort implements BootstrapPort { done(): WorkerToHost | undefined { return this.sent.find(message => message.type === 'done') } + + doneValue(): unknown { + const done = this.done() + return done?.type === 'done' && done.value !== undefined ? decodeWorkerJson(done.value) : undefined + } } function fakeStreams(): { stdout: PatchableStream; stderr: PatchableStream } { return { stdout: { write: () => true }, stderr: { write: () => true } } } -const BOOT = { maxLogBytes: 65_536, maxValueBytes: 32_768 } +/** Capture one promise rejection without Vitest's intentionally `any` matcher channel. */ +async function rejectionOf(promise: Promise): Promise { + try { + await promise + return undefined + } catch (error: unknown) { + return error + } +} + +const BOOT = { maxOutputBytes: 65_536 } +const TOOL_ERROR_CLASS = { name: 'ToolCallError', memberNameProperty: 'toolName' } as const + +/** One worker declaration for the Code Mode tools namespace. */ +function toolNamespace(names: string[]) { + return { global: 'tools', names, errorClass: TOOL_ERROR_CLASS } +} describe('LogBuffer', () => { - it('streams entries to the sink until the byte budget, then emits one marker and drops the rest', () => { + it('streams entries to the sink until the byte budget, then emits one fitting prefix and reports the limit once', () => { const seen: string[] = [] - const buffer = new LogBuffer(10, text => seen.push(text)) + let limits = 0 + const buffer = new LogBuffer(15, text => seen.push(text), () => { limits += 1 }) buffer.push('12345') buffer.push('123456') buffer.push('dropped') - expect(seen).toEqual([ - '12345', - '[dsh-code-runtime-worker] log capture truncated at 10 bytes', - ]) + expect(seen).toEqual(['12345', '123']) + expect(limits).toBe(1) + expect(buffer.remainingOutputBytes()).toBe(0) + + const exactlyFull: string[] = [] + const fullBuffer = new LogBuffer(6, text => exactlyFull.push(text)) + fullBuffer.push('12') + fullBuffer.push('no-prefix-fits') + expect(exactlyFull).toEqual(['12']) }) }) @@ -109,66 +137,87 @@ describe('captureStreamWrites', () => { }) }) -describe('prepareValue', () => { - it('omits undefined, passes small cloneable values raw', () => { - expect(prepareValue(undefined, 100)).toEqual({}) - expect(prepareValue({ a: [1, 'two'] }, 100)).toEqual({ value: { a: [1, 'two'] } }) +describe('prepareCompletion', () => { + it('omits undefined and passes lossless JSON values exactly', () => { + expect(prepareCompletion(undefined, 100)).toEqual({}) + expect(prepareCompletion({ a: [1, 'two'] }, 100)).toEqual({ value: encodeWorkerJson({ a: [1, 'two'] }) }) }) - it('replaces a non-cloneable value with its rendering', () => { - const { value } = prepareValue({ fn: () => 1 }, 1_000) - expect(typeof value).toBe('string') - expect(value).toContain('fn') + it('turns every lossy completion shape into invalid-output', () => { + const cyclic: Record = {} + cyclic.self = cyclic + const sparse = Array(2) + class Exotic { readonly marker = true } + for (const value of [{ fn: () => 1 }, -0, Number.POSITIVE_INFINITY, sparse, cyclic, new Exotic()]) { + expect(prepareCompletion(value, 1_000)).toEqual({ + error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' }, + }) + } }) - it('replaces an oversized value with a truncation-marked capped rendering', () => { - const { value } = prepareValue('x'.repeat(50), 10) - expect(value).toBe(`${'x'.repeat(10)}… [truncated]`) + it('reports an oversized value instead of substituting rendered text', () => { + expect(prepareCompletion('x'.repeat(50), 10)).toEqual({ + error: { kind: 'output-limit', message: 'outer output exceeded 10 bytes' }, + }) }) - it('measures a container by its structured-clone wire size, not its bounded rendering', () => { - // The bounded inspect rendering of a huge array is tiny ("... N more - // items"), but its real cross-boundary size is not — the cap must catch - // it, replacing the value with that bounded rendering. - const huge = new Array(50_000).fill(7) - const { value } = prepareValue(huge, 1_000) - expect(typeof value).toBe('string') - expect(value).toContain('more items') + it('measures the exact JSON serialization at and over the boundary', () => { + expect(prepareCompletion('€', 5)).toEqual({ value: encodeWorkerJson('€') }) + expect(prepareCompletion('€', 4)).toEqual({ + error: { kind: 'output-limit', message: 'outer output exceeded 4 bytes' }, + }) }) - it('caps a multibyte string by UTF-8 bytes, not UTF-16 length', () => { - // 4 code units but 12 UTF-8 bytes: a length-counting cap would pass the - // full string through untruncated. - expect(prepareValue('€€€€', 4)).toEqual({ value: '€… [truncated]' }) + it('contains a getter failure as invalid-output', () => { + const value = Object.defineProperty({}, 'x', { enumerable: true, get() { throw new Error('getter exploded') } }) + expect(prepareCompletion(value, 1_000)).toEqual({ + error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' }, + }) }) - it('caps a multibyte rendering by UTF-8 bytes too', () => { - // Wire size (24-byte string inside an array) exceeds the cap, so the - // value crosses as its rendering — whose truncation must also be - // byte-exact: "[ '" (3 bytes) + two € (6 bytes) = 9; a third € would - // overflow the 10-byte budget. - expect(prepareValue(['€€€€€€€€'], 10)).toEqual({ value: "[ '€€… [truncated]" }) + it('uses the remaining combined budget for invalid-output diagnostics', () => { + expect(prepareCompletion(() => 1, 4, 64)).toEqual({ + error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' }, + }) }) }) -describe('truncateUtf8Bytes', () => { - it('returns a fitting string whole', () => { - expect(truncateUtf8Bytes('fits', 4)).toBe('fits') +describe('prepareException', () => { + it('passes a fitting diagnostic and rejects one byte over without carrying its text', () => { + expect(prepareException('boom', 6, 64)).toEqual({ error: { kind: 'exception', message: 'boom' } }) + expect(prepareException('boom', 5, 64)).toEqual({ + error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' }, + }) }) - it('cuts at a code-point boundary, never mid-surrogate-pair', () => { - // Each 😀 is one code point, two code units, four UTF-8 bytes: a 5-byte - // budget fits exactly one — and never leaves a lone surrogate behind. - const cut = truncateUtf8Bytes('😀😀', 5) - expect(cut).toBe('😀') - expect(Buffer.byteLength(truncateUtf8Bytes('😀😀', 3), 'utf8')).toBe(0) + it('contains a thrown value whose string conversion fails', () => { + const thrown = { toString() { throw new Error('cannot render') } } + expect(prepareException(thrown, 1_000)).toEqual({ + error: { kind: 'exception', message: 'program threw an unrenderable value' }, + }) + + const strangeStack = Object.defineProperty(new Error('ignored'), 'stack', { value: 42 }) + expect(prepareException(strangeStack, 1_000)).toEqual({ + error: { kind: 'exception', message: '42' }, + }) }) }) describe('makeNamespaces', () => { + it('rejects a malformed success reply instead of resolving a lossy binding value', async () => { + const port = new FakePort() + const pending = new Map() + wireReplies(port, pending) + const result = new Promise((resolve, reject) => { pending.set(1, { resolve, reject }) }) + port.deliver({ type: 'reply', id: 1, ok: true, value: [undefined] as never }) + await expect(result).rejects.toThrow('binding resolution must be lossless JSON') + }) + it('exposes prototype-colliding names as ordinary own properties', async () => { const port = new FakePort() - port.respond = message => message.type === 'call' ? { type: 'reply', id: message.id, ok: true, value: `${message.name}-ok` } : undefined + port.respond = message => message.type === 'call' + ? { type: 'reply', id: message.id, ok: true, value: encodeWorkerJson(`${message.name}-ok`) } + : undefined const pending = new Map() wireReplies(port, pending) const [tools] = makeNamespaces({ namespaces: [{ global: 'tools', names: ['__proto__', 'constructor', 'toString'] }] }, port, pending, { value: 1 }) as [Record Promise>] @@ -178,7 +227,7 @@ describe('makeNamespaces', () => { await expect(tools['toString']?.({})).resolves.toBe('toString-ok') }) - it('rejects a non-cloneable argument without leaking the pending entry', async () => { + it('rejects a postMessage clone failure without leaking the pending entry', async () => { let firstCall = true const throwingPort: BootstrapPort = { // First call throws an Error (the real DataCloneError shape), the @@ -190,24 +239,108 @@ describe('makeNamespaces', () => { on: () => {}, } const pending = new Map() - const [tools] = makeNamespaces({ namespaces: [{ global: 'tools', names: ['x'] }] }, throwingPort, pending, { value: 1 }) as [Record Promise>] - await expect(tools.x?.(() => 1)).rejects.toThrow(/structured-cloneable: DataCloneError-ish/) - await expect(tools.x?.(() => 1)).rejects.toThrow(/structured-cloneable: raw-clone-failure/) + const data = { namespaces: [toolNamespace(['x'])] } + const errorClasses = makeBindingErrorClasses(data) + const ToolCallError = errorClasses.get('tools') + const [tools] = makeNamespaces( + data, + throwingPort, + pending, + { value: 1 }, + errorClasses, + ) as [Record Promise>] + const first = await rejectionOf(tools.x?.({ first: true }) ?? Promise.resolve()) + const second = await rejectionOf(tools.x?.({ second: true }) ?? Promise.resolve()) + expect(first).toMatchObject({ name: 'ToolCallError', toolName: 'x' }) + expect(second).toMatchObject({ name: 'ToolCallError', toolName: 'x' }) + expect(first).toBeInstanceOf(ToolCallError) + expect(second).toBeInstanceOf(ToolCallError) + expect((first as Error).message).toMatch(/DataCloneError-ish/) + expect((second as Error).message).toMatch(/raw-clone-failure/) expect(pending.size).toBe(0) }) + + it('rejects lossy arguments before posting or allocating a call id', async () => { + let posts = 0 + const port: BootstrapPort = { postMessage: () => { posts += 1 }, on: () => {} } + const pending = new Map() + const nextId = { value: 1 } + const [tools] = makeNamespaces( + { namespaces: [toolNamespace(['x'])] }, port, pending, nextId, + ) as [Record Promise>] + const decorated = [1] + Object.defineProperty(decorated, 'extra', { value: true }) + const throwing = Object.defineProperty({}, 'value', { + enumerable: true, + get: () => { throw new Error('getter exploded') }, + }) + + for (const value of [() => 1, new Date(), decorated, throwing]) { + const failure = await rejectionOf(tools.x?.(value) ?? Promise.resolve()) + expect(failure).toMatchObject({ + name: 'ToolCallError', toolName: 'x', message: 'binding arguments must be lossless JSON', + }) + } + expect(posts).toBe(0) + expect(pending.size).toBe(0) + expect(nextId.value).toBe(1) + }) + + it('uses ordinary Error for non-tools namespace failures', async () => { + const deniedPort = new FakePort() + deniedPort.respond = message => message.type === 'call' + ? { type: 'reply', id: message.id, ok: false, message: 'helper denied' } + : undefined + const deniedPending = new Map() + wireReplies(deniedPort, deniedPending) + const [helpers] = makeNamespaces({ namespaces: [{ global: 'helpers', names: ['x'] }] }, deniedPort, deniedPending, { value: 1 }) as [Record Promise>] + const denied = await rejectionOf(helpers.x?.({}) ?? Promise.resolve()) + expect(denied).toBeInstanceOf(Error) + expect(denied).toMatchObject({ name: 'Error', message: 'helper denied' }) + expect(denied).not.toHaveProperty('toolName') + + const invalid = await rejectionOf(helpers.x?.(() => 1) ?? Promise.resolve()) + expect(invalid).toBeInstanceOf(Error) + expect((invalid as Error).message).toBe('binding arguments must be lossless JSON') + + const clonePort: BootstrapPort = { postMessage: () => { throw new Error('clone failed') }, on: () => {} } + const [cloneHelpers] = makeNamespaces({ namespaces: [{ global: 'helpers', names: ['x'] }] }, clonePort, new Map(), { value: 1 }) as [Record Promise>] + const cloneFailure = await rejectionOf(cloneHelpers.x?.({}) ?? Promise.resolve()) + expect(cloneFailure).toBeInstanceOf(Error) + expect(cloneFailure).not.toHaveProperty('toolName') + }) }) describe('runWorkerMain', () => { it('runs a program end-to-end: bindings, console, return value', async () => { const port = new FakePort() - port.respond = message => message.type === 'call' ? { type: 'reply', id: message.id, ok: true, value: (message.args as { n: number }).n * 2 } : undefined + port.respond = (message) => { + if (message.type !== 'call') return undefined + const args = decodeWorkerJson(message.args) as { n: number } + return { type: 'reply', id: message.id, ok: true, value: encodeWorkerJson(args.n * 2) } + } await runWorkerMain(port, { ...BOOT, code: 'const doubled = await tools.double({ n: 21 }); console.log("got", doubled); return { doubled };', namespaces: [{ global: 'tools', names: ['double'] }], }, fakeStreams()) expect(port.logs()).toEqual(['got 42']) - expect(port.done()).toEqual({ type: 'done', value: { doubled: 42 } }) + expect(port.doneValue()).toEqual({ doubled: 42 }) + }) + + it('reports worker-side log capture overflow before completing', async () => { + const port = new FakePort() + await runWorkerMain(port, { + maxOutputBytes: 4, + code: 'console.log("12345"); return null', + namespaces: [], + }, fakeStreams()) + expect(port.logs()).toEqual([]) + expect(port.sent).toContainEqual({ type: 'output-limit' }) + expect(port.done()).toEqual({ + type: 'done', + error: { kind: 'output-limit', message: 'outer output exceeded 4 bytes' }, + }) }) it('reports a thrown program error on the done message', async () => { @@ -215,6 +348,7 @@ describe('runWorkerMain', () => { await runWorkerMain(port, { ...BOOT, code: 'throw new Error("boom")', namespaces: [] }, fakeStreams()) const done = port.done() expect(done?.type).toBe('done') + expect(done?.type === 'done' ? done.error?.kind : undefined).toBe('exception') expect(done?.type === 'done' ? done.error?.message : undefined).toContain('boom') expect(done?.type === 'done' ? done.value : undefined).toBeUndefined() }) @@ -222,11 +356,35 @@ describe('runWorkerMain', () => { it('renders non-Error throws and stack-less Errors on the done message', async () => { const rawPort = new FakePort() await runWorkerMain(rawPort, { ...BOOT, code: 'throw "raw-throw"', namespaces: [] }, fakeStreams()) - expect(rawPort.done()).toEqual({ type: 'done', error: { message: 'raw-throw' } }) + expect(rawPort.done()).toEqual({ type: 'done', error: { kind: 'exception', message: 'raw-throw' } }) const barePort = new FakePort() await runWorkerMain(barePort, { ...BOOT, code: 'const e = new Error("bare"); e.stack = undefined; throw e', namespaces: [] }, fakeStreams()) - expect(barePort.done()).toEqual({ type: 'done', error: { message: 'bare' } }) + expect(barePort.done()).toEqual({ type: 'done', error: { kind: 'exception', message: 'bare' } }) + }) + + it('replaces giant thrown strings and Error stacks before posting the done message', async () => { + const rawPort = new FakePort() + await runWorkerMain(rawPort, { + maxOutputBytes: 64, + code: 'throw "x".repeat(1_000_000)', + namespaces: [], + }, fakeStreams()) + expect(rawPort.done()).toEqual({ + type: 'done', + error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' }, + }) + + const stackPort = new FakePort() + await runWorkerMain(stackPort, { + maxOutputBytes: 64, + code: 'throw new Error("x".repeat(1_000_000))', + namespaces: [], + }, fakeStreams()) + expect(stackPort.done()).toEqual({ + type: 'done', + error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' }, + }) }) it('surfaces a host failure reply as a program-side rejection it can catch', async () => { @@ -234,10 +392,27 @@ describe('runWorkerMain', () => { port.respond = message => message.type === 'call' ? { type: 'reply', id: message.id, ok: false, message: 'denied by host' } : undefined await runWorkerMain(port, { ...BOOT, - code: 'try { await tools.x({}) } catch (error) { return `caught: ${error.message}` }', - namespaces: [{ global: 'tools', names: ['x'] }], + code: 'try { await tools.x({}) } catch (error) { return { caught: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message } }', + namespaces: [toolNamespace(['x'])], }, fakeStreams()) - expect(port.done()).toEqual({ type: 'done', value: 'caught: denied by host' }) + expect(port.doneValue()).toEqual({ caught: true, name: 'ToolCallError', toolName: 'x', message: 'denied by host' }) + }) + + it('materializes a consumer-declared rejection class without knowing the namespace', async () => { + const port = new FakePort() + port.respond = message => message.type === 'call' + ? { type: 'reply', id: message.id, ok: false, message: 'helper denied' } + : undefined + await runWorkerMain(port, { + ...BOOT, + code: 'try { await helpers.x({}) } catch (error) { return { caught: error instanceof HelperCallError, name: error.name, helperName: error.helperName, message: error.message } }', + namespaces: [{ + global: 'helpers', + names: ['x'], + errorClass: { name: 'HelperCallError', memberNameProperty: 'helperName' }, + }], + }, fakeStreams()) + expect(port.doneValue()).toEqual({ caught: true, name: 'HelperCallError', helperName: 'x', message: 'helper denied' }) }) it('ignores replies for unknown pending ids', async () => { @@ -245,15 +420,15 @@ describe('runWorkerMain', () => { port.respond = (message) => { if (message.type !== 'call') return undefined // Deliver a stray reply first; the real one follows. - port.deliver({ type: 'reply', id: 9_999, ok: true, value: 'stray' }) - return { type: 'reply', id: message.id, ok: true, value: 'real' } + port.deliver({ type: 'reply', id: 9_999, ok: true, value: encodeWorkerJson('stray') }) + return { type: 'reply', id: message.id, ok: true, value: encodeWorkerJson('real') } } await runWorkerMain(port, { ...BOOT, code: 'return await tools.x({})', namespaces: [{ global: 'tools', names: ['x'] }], }, fakeStreams()) - expect(port.done()).toEqual({ type: 'done', value: 'real' }) + expect(port.doneValue()).toBe('real') }) it('captures raw stream writes through the patched process streams', async () => { diff --git a/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts b/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts index ff68fd09fe..4c6098a2ec 100644 --- a/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts +++ b/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts @@ -23,8 +23,15 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => { const ctx = new Context() await ctx.plugin(WorkerCodeRuntime, {}) const result = await ctx.codeRuntime.run({ - program: 'const doubled: number = await tools.double({ n: 21 }); console.log("halfway", doubled); return doubled;', - bindings: [{ global: 'tools', functions: { double: async args => args.n * 2 } }], + program: 'const doubled: number = await tools.double({ n: 21 }); console.log("halfway", doubled); let failure; try { await tools.fail({}) } catch (error) { failure = { typed: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message } } return { doubled, failure };', + bindings: [{ + global: 'tools', + functions: { + double: async args => args.n * 2, + fail: async () => { throw new Error('denied') }, + }, + errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' }, + }], }) console.log(JSON.stringify(result)) process.exit(0) @@ -40,7 +47,10 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => { const lastLine = stdout.trim().split('\n').at(-1) ?? '' const result = JSON.parse(lastLine) as { value?: unknown; logs: string[]; error?: unknown } expect(result.error).toBeUndefined() - expect(result.value).toBe(42) + expect(result.value).toEqual({ + doubled: 42, + failure: { typed: true, name: 'ToolCallError', toolName: 'fail', message: 'denied' }, + }) expect(result.logs).toContain('halfway 42') }) }) diff --git a/packages/code-runtime/code-runtime-worker/tests/output-json.spec.ts b/packages/code-runtime/code-runtime-worker/tests/output-json.spec.ts new file mode 100644 index 0000000000..9d3bc4d5ef --- /dev/null +++ b/packages/code-runtime/code-runtime-worker/tests/output-json.spec.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from 'vitest' +import type { CodeJsonValue } from '@deepseek-ai/dsh-code-runtime' +import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from '../src/output-json.ts' + +describe('truncateJsonStringBytes', () => { + it('returns a fitting string whole and rejects budgets without JSON quotes', () => { + expect(truncateJsonStringBytes('fits', 6)).toBe('fits') + expect(truncateJsonStringBytes('x', 1)).toBe('') + expect(jsonStringBytesUpTo('fits', 6)).toBe(6) + expect(jsonStringBytesUpTo('fits', 5)).toBeUndefined() + }) + + it('accounts every JSON escape and cuts only between complete code points', () => { + const prefix = '"\\\b\t\n\f\r\u0000😀\ud800€a' + const text = `${prefix}z` + const budget = Buffer.byteLength(JSON.stringify(prefix), 'utf8') + + expect(truncateJsonStringBytes(text, budget)).toBe(prefix) + expect(Buffer.byteLength(JSON.stringify(truncateJsonStringBytes(text, budget)), 'utf8')).toBe(budget) + }) + + it('bounds hostile strings without materializing their complete escaped form', () => { + const stringify = vi.spyOn(JSON, 'stringify').mockImplementation(() => { throw new Error('must not stringify') }) + try { + expect(jsonStringBytesUpTo('"'.repeat(10_000), 32)).toBeUndefined() + expect(truncateJsonStringBytes('"'.repeat(10_000), 32)).toBe('"'.repeat(15)) + } finally { + stringify.mockRestore() + } + }) +}) + +describe('jsonValueBytesUpTo', () => { + it('matches JSON serialization for every lossless value branch and stops at the cap', () => { + const value = { + empty: {}, + nil: null, + yes: true, + no: false, + number: 1.5, + text: '"\n😀', + array: [1, 'x'], + } + const bytes = Buffer.byteLength(JSON.stringify(value), 'utf8') + + expect(jsonValueBytesUpTo(value, bytes)).toBe(bytes) + expect(jsonValueBytesUpTo(value, bytes - 1)).toBeUndefined() + expect(jsonValueBytesUpTo({}, 1)).toBeUndefined() + expect(jsonValueBytesUpTo([], 1)).toBeUndefined() + expect(jsonValueBytesUpTo([], 2)).toBe(2) + expect(jsonValueBytesUpTo(null, 3)).toBeUndefined() + expect(jsonValueBytesUpTo(10, 1)).toBeUndefined() + expect(jsonValueBytesUpTo(false, 4)).toBeUndefined() + expect(jsonValueBytesUpTo(new Array(1), 10)).toBeUndefined() + expect(jsonValueBytesUpTo([null], 5)).toBeUndefined() + expect(jsonValueBytesUpTo([0, 0], 3)).toBeUndefined() + expect(jsonValueBytesUpTo({ a: null, b: null }, 10)).toBeUndefined() + expect(jsonValueBytesUpTo({ long: null }, 2)).toBeUndefined() + expect(jsonValueBytesUpTo({ '': null }, 4)).toBeUndefined() + expect(jsonValueBytesUpTo({ a: null }, 9)).toBeUndefined() + expect(jsonValueBytesUpTo({ a: undefined } as unknown as CodeJsonValue, 100)).toBeUndefined() + }) + + it('meters deeply nested arrays without recursive stack growth', () => { + let value: CodeJsonValue = null + for (let depth = 0; depth < 5_000; depth++) value = [value] + + expect(jsonValueBytesUpTo(value, 10_004)).toBe(10_004) + expect(jsonValueBytesUpTo(value, 10_003)).toBeUndefined() + }) + + it('uses module-captured intrinsics after model-visible globals are mutated', () => { + const value: CodeJsonValue = { payload: ['€', 42] } + const bytes = Buffer.byteLength(JSON.stringify(value), 'utf8') + const arrayIsArrayDescriptor = Object.getOwnPropertyDescriptor(Array, 'isArray')! + const arrayPopDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'pop')! + const arrayPushDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'push')! + const byteLengthDescriptor = Object.getOwnPropertyDescriptor(Buffer, 'byteLength')! + const objectKeysDescriptor = Object.getOwnPropertyDescriptor(Object, 'keys')! + const charCodeAtDescriptor = Object.getOwnPropertyDescriptor(String.prototype, 'charCodeAt')! + const codePointAtDescriptor = Object.getOwnPropertyDescriptor(String.prototype, 'codePointAt')! + const sliceDescriptor = Object.getOwnPropertyDescriptor(String.prototype, 'slice')! + let measured: number | undefined + let prefix = '' + try { + Array.isArray = (_value: unknown): _value is never[] => false + Array.prototype.pop = () => { throw new Error('mutated pop') } + Array.prototype.push = () => { throw new Error('mutated push') } + Buffer.byteLength = () => 0 + Object.keys = () => [] + String.prototype.charCodeAt = () => { throw new Error('mutated charCodeAt') } + String.prototype.codePointAt = () => { throw new Error('mutated codePointAt') } + String.prototype.slice = () => { throw new Error('mutated slice') } + measured = jsonValueBytesUpTo(value, bytes) + prefix = truncateJsonStringBytes('€x', 5) + } finally { + Object.defineProperty(Array, 'isArray', arrayIsArrayDescriptor) + Object.defineProperty(Array.prototype, 'pop', arrayPopDescriptor) + Object.defineProperty(Array.prototype, 'push', arrayPushDescriptor) + Object.defineProperty(Buffer, 'byteLength', byteLengthDescriptor) + Object.defineProperty(Object, 'keys', objectKeysDescriptor) + Object.defineProperty(String.prototype, 'charCodeAt', charCodeAtDescriptor) + Object.defineProperty(String.prototype, 'codePointAt', codePointAtDescriptor) + Object.defineProperty(String.prototype, 'slice', sliceDescriptor) + } + expect(measured).toBe(bytes) + expect(prefix).toBe('€') + }) +}) diff --git a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts index ae2cb2b639..f213deede3 100644 --- a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker' import type { Config } from '@deepseek-ai/dsh-code-runtime-worker' -import type { CodeRunResult } from '@deepseek-ai/dsh-code-runtime' +import type { CodeBindingFunction, CodeBindingNamespace, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' /** * Integration suite over REAL worker threads (no mocks — workers are cheap @@ -17,8 +17,12 @@ async function setup(config: Config = {}) { } /** Convenience: one namespace `tools` with the given functions. */ -function tools(functions: Record Promise>) { - return [{ global: 'tools', functions }] +function tools(functions: Record Promise>): CodeBindingNamespace[] { + return [{ + global: 'tools', + functions: functions as Record, + errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' }, + }] } describe('WorkerCodeRuntime — programs and bindings (real workers)', () => { @@ -52,10 +56,10 @@ describe('WorkerCodeRuntime — programs and bindings (real workers)', () => { const result = await runtime.run({ program: ` const first = await tools.echo({ n: 1 }); - let caught = ''; - try { await tools.fail({}) } catch (error) { caught = error.message } - let caughtRaw = ''; - try { await tools.failRaw({}) } catch (error) { caughtRaw = error.message } + let caught = {}; + try { await tools.fail({}) } catch (error) { caught = { isTyped: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message } } + let caughtRaw = {}; + try { await tools.failRaw({}) } catch (error) { caughtRaw = { name: error.name, toolName: error.toolName, message: error.message } } return { first, caught, caughtRaw }; `, bindings: tools({ @@ -66,10 +70,61 @@ describe('WorkerCodeRuntime — programs and bindings (real workers)', () => { }), }) expect(result.error).toBeUndefined() - expect(result.value).toEqual({ first: { echoed: { n: 1 } }, caught: 'nope', caughtRaw: 'raw-nope' }) + expect(result.value).toEqual({ + first: { echoed: { n: 1 } }, + caught: { isTyped: true, name: 'ToolCallError', toolName: 'fail', message: 'nope' }, + caughtRaw: { name: 'ToolCallError', toolName: 'failRaw', message: 'raw-nope' }, + }) expect(calls).toEqual([{ n: 1 }]) }) + it('materializes a typed rejection from a generic namespace descriptor', async () => { + const { runtime } = await setup() + const result = await runtime.run({ + program: ` + try { await helpers.fail({}) } catch (error) { + return { + isTyped: error instanceof HelperCallError, + name: error.name, + helperName: error.helperName, + message: error.message, + }; + } + `, + bindings: [{ + global: 'helpers', + functions: { fail: async () => { throw new Error('nope') } }, + errorClass: { name: 'HelperCallError', memberNameProperty: 'helperName' }, + }], + }) + expect(result.value).toEqual({ + isTyped: true, + name: 'HelperCallError', + helperName: 'fail', + message: 'nope', + }) + }) + + it('bridges a deeply nested lossless JSON argument, resolution, and completion', async () => { + const { runtime } = await setup() + const result = await runtime.run({ + program: ` + let value = 'leaf'; + for (let depth = 0; depth < 3_000; depth++) value = [value]; + return await tools.echo(value); + `, + bindings: tools({ echo: async args => args }), + }) + + expect(result.error).toBeUndefined() + let cursor = result.value + for (let depth = 0; depth < 3_000; depth++) { + expect(Array.isArray(cursor)).toBe(true) + cursor = Array.isArray(cursor) ? cursor[0] : undefined + } + expect(cursor).toBe('leaf') + }, 15_000) + it('reports non-erasable syntax as an exception without spawning a worker', async () => { const { runtime } = await setup() const result = await runtime.run({ program: 'enum E { A }\nreturn 1', bindings: [] }) @@ -90,10 +145,11 @@ describe('WorkerCodeRuntime — programs and bindings (real workers)', () => { expect(result.value).toBe('{}') }) - it('replaces a non-cloneable return value with a string rendering', async () => { + it('rejects a non-lossless completion instead of replacing it with rendered text', async () => { const { runtime } = await setup() const result = await runtime.run({ program: 'return { f: () => 1 }', bindings: [] }) - expect(typeof result.value).toBe('string') + expect(result.value).toBeUndefined() + expect(result.error).toEqual({ kind: 'invalid-output', message: 'program completion must be lossless JSON' }) }) it('completes a program that returns nothing with no value at all', async () => { @@ -166,6 +222,19 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => { expect(result.error).toEqual({ kind: 'abort', message: 'too-late' }) }) + it('applies the outer-output cap to failures before worker startup', async () => { + const capped = await setup({ maxOutputBytes: 64 }) + const controller = new AbortController() + controller.abort('A'.repeat(1_000)) + const aborted = await capped.runtime.run({ program: 'return 1', bindings: [], signal: controller.signal }) + expect(aborted).toEqual({ logs: [], error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' } }) + + const minimal = await setup({ maxOutputBytes: 4 }) + const invalid = await minimal.runtime.run({ program: 'enum E { A }\nreturn 1', bindings: [] }) + expect(invalid.error?.kind).toBe('output-limit') + expect(Buffer.byteLength(JSON.stringify(invalid.logs), 'utf8') + Buffer.byteLength(JSON.stringify(invalid.error?.message), 'utf8')).toBeLessThanOrEqual(4) + }) + it('drops a binding resolution that lands after the run settled', async () => { const { runtime } = await setup() const controller = new AbortController() @@ -201,30 +270,94 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => { expect(after.value).toBe('alive') }, 30_000) - it('truncates runaway log output at the byte budget with an in-band marker', async () => { - const { runtime } = await setup({ maxLogBytes: 300 }) + it('reports a worker that exits before publishing a completion', async () => { + const { runtime } = await setup() + const result = await runtime.run({ program: 'process.exit(7)', bindings: [] }) + expect(result).toEqual({ + logs: [], + error: { kind: 'worker-exit', message: 'worker exited with code 7 before completing' }, + }) + }) + + it('fails runaway log output explicitly while retaining a bounded prefix', async () => { + const { runtime } = await setup({ maxOutputBytes: 300 }) const result = await runtime.run({ program: 'for (let i = 0; i < 1000; i++) console.log("spam line", i); return 1', bindings: [], }) - expect(result.logs.at(-1)).toContain('truncated at 300 bytes') - const total = result.logs.reduce((sum, text) => sum + Buffer.byteLength(text, 'utf8'), 0) - expect(total).toBeLessThan(1_000) + expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 300 bytes' }) + expect(result.value).toBeUndefined() + expect(result.logs.length).toBeGreaterThan(0) + expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')).toBeLessThan(300) }) - it('caps an oversized return value with a truncation marker', async () => { - const { runtime } = await setup({ maxValueBytes: 64 }) + it('retains a fitting prefix when one oversized log is the first output', async () => { + const { runtime } = await setup({ maxOutputBytes: 96 }) + const result = await runtime.run({ + program: 'console.log(`start-${`😀"\\\\\\n`.repeat(100)}`); return null', + bindings: [], + }) + expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 96 bytes' }) + expect(result.logs).toHaveLength(1) + expect(result.logs[0]?.startsWith('start-')).toBe(true) + expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8') + + Buffer.byteLength(JSON.stringify(result.error?.message), 'utf8')).toBeLessThanOrEqual(96) + }) + + it('fails an oversized return value without substituting a string', async () => { + const { runtime } = await setup({ maxOutputBytes: 64 }) const result = await runtime.run({ program: 'return "y".repeat(10_000)', bindings: [] }) - expect(result.value).toBe(`${'y'.repeat(64)}… [truncated]`) + expect(result.value).toBeUndefined() + expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 64 bytes' }) }) - it('caps a multibyte return value by UTF-8 bytes, not string length', async () => { - // 4 code units, 12 UTF-8 bytes: a length-counting cap would let the full - // string cross. The worker's byte-exact capped rendering then passes the - // host re-cap unchanged (cap + marker is exactly the granted slack). - const { runtime } = await setup({ maxValueBytes: 4 }) - const result = await runtime.run({ program: 'return "€€€€"', bindings: [] }) - expect(result.value).toBe('€… [truncated]') + it('uses UTF-8 serialized bytes at the exact completion boundary', async () => { + const exact = await setup({ maxOutputBytes: 7 }) + const exactResult = await exact.runtime.run({ program: 'return "€"', bindings: [] }) + // [] costs two bytes and JSON serialization of "€" costs five. + expect(exactResult).toEqual({ logs: [], value: '€' }) + + const over = await setup({ maxOutputBytes: 6 }) + const overResult = await over.runtime.run({ program: 'return "€"', bindings: [] }) + expect(overResult.error?.kind).toBe('output-limit') + }) + + it('accounts logs and completion in one exact combined ledger', async () => { + // JSON(["abc"]) is seven bytes and JSON("xy") is four. + const exact = await setup({ maxOutputBytes: 11 }) + expect(await exact.runtime.run({ program: 'console.log("abc"); return "xy"', bindings: [] })) + .toEqual({ logs: ['abc'], value: 'xy' }) + + const over = await setup({ maxOutputBytes: 10 }) + const result = await over.runtime.run({ program: 'console.log("abc"); return "xy"', bindings: [] }) + expect(result.value).toBeUndefined() + expect(result.error?.kind).toBe('output-limit') + expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8') + Buffer.byteLength(JSON.stringify(result.error?.message), 'utf8')).toBeLessThanOrEqual(10) + }) + + it('accounts logs and exception diagnostics before the worker port boundary', async () => { + // JSON(["abc"]) is seven bytes and JSON("xy") is four. + const exact = await setup({ maxOutputBytes: 11 }) + expect(await exact.runtime.run({ program: 'console.log("abc"); throw "xy"', bindings: [] })) + .toEqual({ logs: ['abc'], error: { kind: 'exception', message: 'xy' } }) + + const over = await setup({ maxOutputBytes: 10 }) + const result = await over.runtime.run({ program: 'console.log("abc"); throw "xy"', bindings: [] }) + expect(result.error?.kind).toBe('output-limit') + expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8') + + Buffer.byteLength(JSON.stringify(result.error?.message), 'utf8')).toBeLessThanOrEqual(10) + }) + + it('does not send a giant Error stack across the worker port', async () => { + const { runtime } = await setup({ maxOutputBytes: 64 }) + const result = await runtime.run({ + program: 'throw new Error("x".repeat(1_000_000))', + bindings: [], + }) + expect(result).toEqual({ + logs: [], + error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' }, + }) }) it('completes a program that awaits its write callback, capturing the chunk', async () => { @@ -241,32 +374,67 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => { expect(result.logs).toContain('flushed') }) - it('caps a huge container whose bounded rendering is small (wire size, not rendering, is what counts)', async () => { + it('returns a large JSON container exactly when the outer cap permits it', async () => { const { runtime } = await setup() const result = await runtime.run({ program: 'return new Array(50_000).fill(7)', bindings: [] }) expect(result.error).toBeUndefined() - expect(typeof result.value).toBe('string') - expect(result.value).toContain('more items') + expect(result.value).toEqual(new Array(50_000).fill(7)) }) - it('captures pipe writes that bypass the patched write slot as stray logs, capped by the same budget', async () => { - const { runtime } = await setup({ maxLogBytes: 4 }) + it('returns an exact completion at the default 64 MiB combined boundary', async () => { + const { runtime } = await setup() + // [] costs two bytes and the JSON string contributes two quotes, leaving + // exactly this many payload bytes under the 67_108_864-byte default. + const result = await runtime.run({ program: 'return "x".repeat(67_108_860)', bindings: [] }) + expect(result.error).toBeUndefined() + expect(result.logs).toEqual([]) + expect(result.value).toHaveLength(67_108_860) + }, 60_000) + + it('fails one byte over the default 64 MiB combined boundary', async () => { + const { runtime } = await setup() + const result = await runtime.run({ program: 'return "x".repeat(67_108_861)', bindings: [] }) + expect(result.value).toBeUndefined() + expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 67108864 bytes' }) + }, 60_000) + + it('accounts pipe writes that bypass the patched write slot in the same outer ledger', async () => { + const { runtime } = await setup({ maxOutputBytes: 80 }) const result = await runtime.run({ // The prototype write bypasses the patched instance and reaches the real pipe. Pauses keep // writes in separate chunks and let both reach the host before settlement. program: ` const write = (text) => Object.getPrototypeOf(process.stdout).write.call(process.stdout, text); - write('abcd'); + write('a'.repeat(20)); await new Promise(resolve => setTimeout(resolve, 150)); - write('ef'); + write('b'.repeat(100)); await new Promise(resolve => setTimeout(resolve, 100)); return 1; `, bindings: [], }) + expect(result.error?.kind).toBe('output-limit') + expect(result.logs).toContain('a'.repeat(20)) + expect(result.logs[1]?.length).toBeGreaterThan(0) + expect('b'.repeat(100).startsWith(result.logs[1] ?? '')).toBe(true) + }, 15_000) + + it('drains pipe output queued before terminal worker teardown completes', async () => { + const { runtime } = await setup({ maxOutputBytes: 200_000 }) + const payload = `late-pipe-${'x'.repeat(100_000)}` + const result = await runtime.run({ + program: ` + const { parentPort } = await import('node:worker_threads'); + const write = (text) => Object.getPrototypeOf(process.stdout).write.call(process.stdout, text); + write('late-pipe-' + 'x'.repeat(100_000)); + parentPort.postMessage({ type: 'done', value: ['done'] }); + for (;;) {} + `, + bindings: [], + }) expect(result.error).toBeUndefined() - expect(result.logs).toContain('abcd') - expect(result.logs).not.toContain('ef') + expect(result.value).toBe('done') + expect(result.logs.join('') === payload).toBe(true) }, 15_000) }) @@ -305,7 +473,8 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => { { type: 'log', text: 7 }, { type: 'log', text: {} }, { type: 'done', error: 5 }, - { type: 'done', error: { message: 5 } }, + { type: 'done', error: { kind: 'exception', message: 5 } }, + { type: 'done', error: { kind: 'invented', message: 'bad kind' } }, ]) parentPort.postMessage(junk); return await tools.real({}); `, @@ -316,67 +485,288 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => { expect(result.logs).toEqual([]) }) - it('caps forged log floods and forged done values at the configured budgets, dropping forged extra fields', async () => { - const { runtime } = await setup({ maxLogBytes: 200, maxValueBytes: 64 }) + it('fails forged log floods and forged done values through the same outer cap', async () => { + const { runtime } = await setup({ maxOutputBytes: 200 }) const result = await runtime.run({ - // Forged messages bypass the worker-side LogBuffer and prepareValue + // Forged messages bypass the worker-side LogBuffer and completion check // entirely — only the host-side ledger and re-cap stand between model // code and an unbounded result. program: ` const { parentPort } = await import('node:worker_threads'); for (let i = 0; i < 50; i++) parentPort.postMessage({ type: 'log', text: 'F'.repeat(100), forged: true }); - parentPort.postMessage({ type: 'done', value: 'V'.repeat(100000) }); + parentPort.postMessage({ type: 'done', value: ['V'.repeat(100000)] }); for (;;) {} `, bindings: [], }) - expect(typeof result.value).toBe('string') - const value = result.value as string - expect(value.startsWith('V'.repeat(64))).toBe(true) - expect(value.endsWith('… [truncated]')).toBe(true) - expect(value.length).toBeLessThan(120) - const marker = '[dsh-code-runtime-worker] log capture truncated at 200 bytes' - const total = result.logs.reduce((sum, text) => sum + Buffer.byteLength(text, 'utf8'), 0) - expect(total).toBeLessThanOrEqual(200 + Buffer.byteLength(marker, 'utf8')) - expect(result.logs.at(-1)).toBe(marker) + expect(result.value).toBeUndefined() + expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 200 bytes' }) + expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')).toBeLessThan(200) }) - it('accepts a forged done carrying both value and error (self-sabotage, contained)', async () => { + it('re-caps an oversized forged done value at the host boundary', async () => { + const { runtime } = await setup({ maxOutputBytes: 64 }) + const result = await runtime.run({ + program: ` + const { parentPort } = await import('node:worker_threads'); + parentPort.postMessage({ type: 'done', value: ['V'.repeat(100_000)] }); + for (;;) {} + `, + bindings: [], + }) + expect(result).toEqual({ + logs: [], + error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' }, + }) + }) + + it('bounds one oversized forged log while retaining its fitting escaped prefix', async () => { + const { runtime } = await setup({ maxOutputBytes: 96 }) + const result = await runtime.run({ + program: ` + const { parentPort } = await import('node:worker_threads'); + parentPort.postMessage({ type: 'log', text: '"'.repeat(1_000_000) }); + for (;;) {} + `, + bindings: [], + }) + expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 96 bytes' }) + expect(result.logs).toHaveLength(1) + expect(result.logs[0]).toMatch(/^"+$/) + expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8') + Buffer.byteLength(JSON.stringify('outer output exceeded 96 bytes'), 'utf8')).toBeLessThanOrEqual(96) + }) + + it('drops a malformed forged done carrying both value and error', async () => { const { runtime } = await setup() const result = await runtime.run({ program: ` const { parentPort } = await import('node:worker_threads'); - parentPort.postMessage({ type: 'done', value: 'lied', error: { message: 'fake failure' } }); - for (;;) {} + parentPort.postMessage({ type: 'done', value: 'lied', error: { kind: 'exception', message: 'fake failure' } }); + return 'honest'; `, bindings: [], }) - expect(result.value).toBe('lied') - expect(result.error).toEqual({ kind: 'exception', message: 'fake failure' }) + expect(result).toEqual({ logs: [], error: { kind: 'exception', message: 'fake failure' } }) }) - it('byte-bounds forged multibyte error text at the host', async () => { - // Forged error text bypasses the worker entirely; the host bound is a - // BYTE bound (two € = 6 bytes fit an 8-byte cap, a third would not). - const { runtime } = await setup({ maxValueBytes: 8 }) + it('contains a deeply nested forged completion without overflowing the host meter', async () => { + const { runtime } = await setup() const result = await runtime.run({ program: ` const { parentPort } = await import('node:worker_threads'); - parentPort.postMessage({ type: 'done', error: { message: '€'.repeat(1000) } }); + const value = []; + for (let depth = 0; depth < 3_000; depth++) value.push({ kind: 'array', length: 1 }); + value.push(null); + setTimeout(() => { parentPort.postMessage({ type: 'done', value }) }, 25); + // Prevent bootstrap's normal undefined completion from racing the forged terminal. + await new Promise(() => {}); + `, + bindings: [], + }) + expect(result.error).toBeUndefined() + let value = result.value + let depth = 0 + while (Array.isArray(value)) { + expect(value).toHaveLength(1) + value = value[0] + depth += 1 + } + expect(depth).toBe(3_000) + expect(value).toBeNull() + }, 15_000) + + it('turns forged over-limit error text into output-limit at the host', async () => { + const { runtime } = await setup({ maxOutputBytes: 64 }) + const result = await runtime.run({ + program: ` + const { parentPort } = await import('node:worker_threads'); + parentPort.postMessage({ type: 'done', error: { kind: 'exception', message: '€'.repeat(1000) } }); for (;;) {} `, bindings: [], }) - expect(result.error).toEqual({ kind: 'exception', message: '€€' }) + expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 64 bytes' }) }) - it('answers a binding whose resolution cannot be cloned with a failure reply', async () => { + it('answers a binding whose resolution is not lossless JSON with a typed failure reply', async () => { const { runtime } = await setup() const result = await runtime.run({ - program: 'try { await tools.bad({}) } catch (error) { return error.message }', + program: 'try { await tools.bad({}) } catch (error) { return { name: error.name, toolName: error.toolName, message: error.message } }', bindings: tools({ bad: async () => (() => 1) }), }) - expect(result.value).toContain('not structured-cloneable') + expect(result.value).toEqual({ name: 'ToolCallError', toolName: 'bad', message: 'binding resolution must be lossless JSON' }) + }) + + it('rejects lossy binding arguments in the worker before invoking the host binding', async () => { + const { runtime } = await setup() + let calls = 0 + const result = await runtime.run({ + program: ` + const decorated = [1]; Object.defineProperty(decorated, 'extra', { value: true }); + const values = [new Date(), decorated, () => 1]; + const failures = []; + for (const value of values) { + try { await tools.never(value) } catch (error) { + failures.push({ typed: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message }); + } + } + return failures; + `, + bindings: tools({ never: async () => { calls += 1; return null } }), + }) + expect(calls).toBe(0) + expect(result.value).toEqual(new Array(3).fill({ + typed: true, + name: 'ToolCallError', + toolName: 'never', + message: 'binding arguments must be lossless JSON', + })) + }) + + it('rejects intrinsic-looking exotic objects as arguments and completions', async () => { + const { runtime } = await setup() + let calls = 0 + const forgeObject = ` + const prototype = Object.create(null); + const SpoofedObject = function Object() {}; + SpoofedObject.prototype = prototype; + Object.defineProperty(prototype, 'constructor', { value: SpoofedObject }); + const forged = Object.assign(Object.create(prototype), { value: 1 }); + Function.prototype.toString = () => 'function Object() { [native code] }'; + ` + const argument = await runtime.run({ + program: `${forgeObject} + try { await tools.never(forged) } catch (error) { + return { typed: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message }; + } + `, + bindings: tools({ never: async () => { calls += 1; return null } }), + }) + expect(calls).toBe(0) + expect(argument.value).toEqual({ + typed: true, + name: 'ToolCallError', + toolName: 'never', + message: 'binding arguments must be lossless JSON', + }) + + const completion = await runtime.run({ program: `${forgeObject}\nreturn forged`, bindings: [] }) + expect(completion).toEqual({ + logs: [], + error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' }, + }) + }) + + it('preserves binding and completion JSON after model code mutates boundary globals', async () => { + const { runtime } = await setup() + const result = await runtime.run({ + program: ` + const arrayPrototype = Array.prototype; + const objectPrototype = Object.prototype; + const setPrototype = Set.prototype; + const stringPrototype = String.prototype; + Array.isArray = () => false; + arrayPrototype.at = arrayPrototype.includes = arrayPrototype.pop = arrayPrototype.push = () => { throw new Error('mutated array method') }; + Object.defineProperty = Object.getOwnPropertyDescriptor = Object.getPrototypeOf = Object.keys = () => { throw new Error('mutated object method') }; + Object.hasOwn = () => false; + Object.is = () => true; + objectPrototype.propertyIsEnumerable = () => false; + Number.isFinite = Number.isSafeInteger = () => false; + Reflect.apply = Reflect.ownKeys = () => { throw new Error('mutated reflect method') }; + setPrototype.add = setPrototype.delete = setPrototype.has = () => { throw new Error('mutated set method') }; + stringPrototype.charCodeAt = stringPrototype.codePointAt = stringPrototype.slice = () => { throw new Error('mutated string method') }; + Buffer.byteLength = () => 0; + Function.prototype.toString = () => 'mutated'; + objectPrototype.get = () => undefined; + objectPrototype.constructor = arrayPrototype.constructor = null; + globalThis.Array = globalThis.Buffer = globalThis.Error = globalThis.Function = globalThis.Number = globalThis.Object = globalThis.Reflect = globalThis.Set = globalThis.String = undefined; + const echoed = await tools.echo({ request: ['€', 1] }); + let failure; + try { await tools.fail({}) } catch (error) { + failure = { typed: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message }; + } + return { echoed, failure, completion: { ok: true, amount: 42 } }; + `, + bindings: tools({ echo: async args => args, fail: async () => { throw new Error('nope') } }), + }) + expect(result).toEqual({ + logs: [], + value: { + echoed: { request: ['€', 1] }, + failure: { typed: true, name: 'ToolCallError', toolName: 'fail', message: 'nope' }, + completion: { ok: true, amount: 42 }, + }, + }) + }) + + it('rejects forged lossy binding arguments again at the host boundary', async () => { + const { runtime } = await setup() + let calls = 0 + const result = await runtime.run({ + program: ` + const { parentPort } = await import('node:worker_threads'); + const forged = (id, args) => new Promise((resolve) => { + const receive = (message) => { + if (message?.type !== 'reply' || message.id !== id) return; + parentPort.off('message', receive); + resolve(message); + }; + parentPort.on('message', receive); + parentPort.postMessage({ type: 'call', id, global: 'tools', name: 'never', args }); + }); + const sparse = []; sparse.length = 1; + const cycle = {}; cycle.self = cycle; + return await Promise.all([ + forged(8001, new Date()), + forged(8002, -0), + forged(8003, sparse), + forged(8004, cycle), + ]); + `, + bindings: tools({ never: async () => { calls += 1; return null } }), + }) + expect(calls).toBe(0) + expect(result.value).toEqual([8001, 8002, 8003, 8004].map(id => ({ + type: 'reply', + id, + ok: false, + message: 'binding arguments must be lossless JSON', + }))) + }) + + it('contains throwing getters while snapshotting binding resolutions', async () => { + const { runtime } = await setup() + const result = await runtime.run({ + program: 'try { await tools.bad({}) } catch (error) { return { name: error.name, toolName: error.toolName, message: error.message } }', + bindings: tools({ bad: async () => Object.defineProperty({}, 'bad', { enumerable: true, get() { throw new Error('getter exploded') } }) }), + }) + expect(result.value).toEqual({ name: 'ToolCallError', toolName: 'bad', message: 'binding resolution must be lossless JSON' }) + }) + + it('revalidates a forged lossy completion at the host boundary', async () => { + const { runtime } = await setup() + const result = await runtime.run({ + program: ` + const { parentPort } = await import('node:worker_threads'); + parentPort.postMessage({ type: 'done', value: -0 }); + for (;;) {} + `, + bindings: [], + }) + expect(result).toEqual({ logs: [], error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' } }) + }) + + it('honors a forged worker-side output-limit signal', async () => { + const { runtime } = await setup() + const result = await runtime.run({ + program: ` + const { parentPort } = await import('node:worker_threads'); + parentPort.postMessage({ type: 'output-limit' }); + for (;;) {} + `, + bindings: [], + }) + expect(result).toEqual({ logs: [], error: { kind: 'output-limit', message: 'outer output exceeded 67108864 bytes' } }) }) it('exposes binding names that collide with Object.prototype as ordinary functions', async () => { @@ -392,7 +782,7 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => { }) describe('WorkerCodeRuntime — seam misuse and lifecycle', () => { - it('rejects invalid binding globals loudly (identifier, reserved word, duplicate, console)', async () => { + it('rejects invalid and duplicate binding globals loudly', async () => { const { runtime } = await setup() const cases: [string, RegExp][] = [ ['not valid!', /not a usable identifier/], @@ -406,6 +796,32 @@ describe('WorkerCodeRuntime — seam misuse and lifecycle', () => { program: 'return 1', bindings: [{ global: 'tools', functions: {} }, { global: 'tools', functions: {} }], })).rejects.toThrow(/duplicate binding global/) + + await expect(runtime.run({ + program: 'return typeof ToolCallError', + bindings: [{ global: 'ToolCallError', functions: {} }], + })).resolves.toMatchObject({ value: 'object' }) + }) + + it('rejects malformed or colliding binding error-class declarations', async () => { + const { runtime } = await setup() + const run = async (bindings: CodeBindingNamespace[]) => await runtime.run({ program: 'return 1', bindings }) + const namespace = (global: string, name: string, memberNameProperty = 'memberName'): CodeBindingNamespace => ({ + global, + functions: {}, + errorClass: { name, memberNameProperty }, + }) + + await expect(run([namespace('tools', 'not valid!')])).rejects.toThrow(/error class.*not a usable identifier/) + await expect(run([namespace('tools', 'await')])).rejects.toThrow(/error class.*not a usable identifier/) + await expect(run([namespace('tools', 'console')])).rejects.toThrow(/duplicate injected global/) + await expect(run([namespace('tools', 'tools')])).rejects.toThrow(/duplicate injected global/) + await expect(run([ + namespace('tools', 'CallError'), + namespace('helpers', 'CallError'), + ])).rejects.toThrow(/duplicate injected global/) + await expect(run([namespace('tools', 'CallError', '')])).rejects.toThrow(/member property.*not usable/) + await expect(run([namespace('tools', 'CallError', 'message')])).rejects.toThrow(/member property.*not usable/) }) it('rejects config values that are not positive numbers', async () => { @@ -413,6 +829,12 @@ describe('WorkerCodeRuntime — seam misuse and lifecycle', () => { await expect(ctx.plugin(WorkerCodeRuntime, { computeMs: -1 })).rejects.toThrow(/positive number/) }) + it('requires maxOutputBytes to fit the smallest counted outer payloads', async () => { + const ctx = new Context() + await expect(ctx.plugin(WorkerCodeRuntime, { maxOutputBytes: 3 })).rejects.toThrow(/safe integer of at least 4/) + await expect(ctx.plugin(WorkerCodeRuntime, { maxOutputBytes: 4.5 })).rejects.toThrow(/safe integer of at least 4/) + }) + it('keeps runs isolated: no state survives from one run to the next', async () => { const { runtime } = await setup() await runtime.run({ program: 'globalThis.leak = "value"; return 1', bindings: [] }) diff --git a/packages/code-runtime/code-runtime-worker/tests/source-worker.compat.spec.ts b/packages/code-runtime/code-runtime-worker/tests/source-worker.compat.spec.ts new file mode 100644 index 0000000000..6ef2775114 --- /dev/null +++ b/packages/code-runtime/code-runtime-worker/tests/source-worker.compat.spec.ts @@ -0,0 +1,39 @@ +import { copyFile, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Worker } from 'node:worker_threads' +import { expect, it } from 'vitest' +import { decodeWorkerJson } from '../src/worker-json.ts' + +/** + * Prove the unbuilt worker is a self-contained source closure. Copying it out + * of the workspace makes any package runtime import fail even when local + * `lib/` artifacts happen to exist. + */ +it('boots the source worker without workspace package outputs', async () => { + const directory = await mkdtemp(join(tmpdir(), 'dsh-code-source-worker-')) + let worker: Worker | undefined + try { + const files = ['worker.ts', 'bootstrap.ts', 'protocol.ts', 'worker-json.ts', 'output-json.ts'] + await Promise.all(files.map(async (file) => { + await copyFile(new URL(`../src/${file}`, import.meta.url), join(directory, file)) + })) + + worker = new Worker(join(directory, 'worker.ts'), { + workerData: { code: 'return { answer: 42 }', namespaces: [], maxOutputBytes: 65_536 }, + env: {}, + execArgv: [], + }) + const message = await new Promise((resolve, reject) => { + worker?.once('message', resolve) + worker?.once('error', reject) + }) + + expect(message).toMatchObject({ type: 'done' }) + const value = typeof message === 'object' && message !== null ? (message as { value?: unknown }).value : undefined + expect(decodeWorkerJson(value)).toEqual({ answer: 42 }) + } finally { + if (worker) await worker.terminate() + await rm(directory, { recursive: true, force: true }) + } +}) diff --git a/packages/code-runtime/code-runtime-worker/tests/worker-json.spec.ts b/packages/code-runtime/code-runtime-worker/tests/worker-json.spec.ts new file mode 100644 index 0000000000..6ef8d30a09 --- /dev/null +++ b/packages/code-runtime/code-runtime-worker/tests/worker-json.spec.ts @@ -0,0 +1,257 @@ +import { runInNewContext } from 'node:vm' +import { describe, expect, it } from 'vitest' +import { snapshotJsonValue } from '@deepseek-ai/dsh-session' +import { decodeWorkerJson, encodeWorkerJson, snapshotCodeJsonValue } from '../src/worker-json.ts' + +describe('snapshotCodeJsonValue', () => { + it('matches the canonical scalar boundary', () => { + const unsupported = [undefined, 1n, Symbol('value'), () => 1] + for (const value of [null, false, 'text', 1.25, -0, Number.NaN, Number.POSITIVE_INFINITY, ...unsupported]) { + expect(snapshotCodeJsonValue(value)).toEqual(snapshotJsonValue(value)) + } + }) + + it('detaches dense arrays and plain or null-prototype records', () => { + const shared = { value: 1 } + const nullPrototype = Object.assign(Object.create(null) as Record, { shared }) + const source = { list: [nullPrototype, shared], alias: shared } + + const snapshot = snapshotCodeJsonValue(source) as Record + shared.value = 2 + + expect(snapshot).toEqual({ list: [{ shared: { value: 1 } }, { value: 1 }], alias: { value: 1 } }) + expect(snapshot).not.toBe(source) + expect((snapshot.list as unknown[])[0]).not.toBe(nullPrototype) + expect(snapshot.alias).not.toBe(shared) + }) + + it('accepts intrinsic plain containers from another JavaScript realm', () => { + const foreign = runInNewContext('({ object: { nested: [1] }, array: [2, { ok: true }] })') as { + object: unknown + array: unknown + } + + expect(snapshotCodeJsonValue(foreign.object)).toEqual({ nested: [1] }) + expect(snapshotCodeJsonValue(foreign.array)).toEqual([2, { ok: true }]) + }) + + it('reads each accepted slot once and preserves a literal __proto__ key', () => { + let objectReads = 0 + let arrayReads = 0 + const source = Object.create(null) as Record + Object.defineProperty(source, '__proto__', { + enumerable: true, + get: () => { + objectReads += 1 + return { safe: true } + }, + }) + const array = new Array(1) + Object.defineProperty(array, 0, { + enumerable: true, + get: () => { + arrayReads += 1 + return arrayReads === 1 ? source : undefined + }, + }) + + const snapshot = snapshotCodeJsonValue(array) as Record[] + + expect(objectReads).toBe(1) + expect(arrayReads).toBe(1) + expect(Object.getPrototypeOf(snapshot[0])).toBe(Object.prototype) + expect(Object.hasOwn(snapshot[0]!, '__proto__')).toBe(true) + expect(snapshot[0]?.['__proto__']).toEqual({ safe: true }) + }) + + it('accepts deeply nested valid JSON without using the JavaScript call stack', () => { + let value: unknown = 'leaf' + for (let depth = 0; depth < 5_000; depth++) value = [value] + + let cursor = snapshotCodeJsonValue(value) + for (let depth = 0; depth < 5_000; depth++) { + expect(Array.isArray(cursor)).toBe(true) + cursor = Array.isArray(cursor) ? cursor[0] : undefined + } + expect(cursor).toBe('leaf') + }) + + it('rejects exotic containers, sparse arrays, cycles, and invalid children', () => { + class ExoticObject { + readonly value = 1 + } + class ExoticArray extends Array {} + const cyclic: Record = {} + cyclic.self = cyclic + const decorated = [1] + Object.defineProperty(decorated, 'extra', { value: true }) + const compensatedSparse = new Array(1) + Object.defineProperty(compensatedSparse, 'extra', { value: true }) + const symbolDecorated = [1] + Object.defineProperty(symbolDecorated, Symbol('extra'), { value: true }) + const hiddenObject = Object.defineProperty({}, 'hidden', { value: true }) + const symbolObject = { [Symbol('extra')]: true } + const customPrototype = Object.create(null) as Record + const customPrototypeObject = Object.assign(Object.create(customPrototype) as Record, { value: 1 }) + const forgedPrototype: unknown[] = [] + Object.setPrototypeOf(forgedPrototype, null) + const forgedArray = [1] + Object.setPrototypeOf(forgedArray, forgedPrototype) + const spoofedObjectPrototype = Object.create(null) as Record + const SpoofedObject = function Object() {} + SpoofedObject.prototype = spoofedObjectPrototype + Object.defineProperty(spoofedObjectPrototype, 'constructor', { value: SpoofedObject }) + const spoofedObject = Object.create(spoofedObjectPrototype) as Record + spoofedObject.value = 1 + const revokedPrototype = Object.create(null) as Record + const RevokedObject = function Object() {} + RevokedObject.prototype = revokedPrototype + const revokedConstructor = Proxy.revocable(RevokedObject, {}) + Object.defineProperty(revokedPrototype, 'constructor', { value: revokedConstructor.proxy }) + const revokedObject = Object.create(revokedPrototype) as Record + revokedConstructor.revoke() + const spoofedArrayPrototype: unknown[] = [] + Object.setPrototypeOf(spoofedArrayPrototype, Object.prototype) + const SpoofedArray = function Array() {} + SpoofedArray.prototype = spoofedArrayPrototype + Object.defineProperty(spoofedArrayPrototype, 'constructor', { value: SpoofedArray }) + const spoofedArray = [1] + Object.setPrototypeOf(spoofedArray, spoofedArrayPrototype) + + for (const value of [ + new ExoticObject(), + new Map([['value', 1]]), + new ExoticArray(1), + new Array(1), + decorated, + compensatedSparse, + symbolDecorated, + hiddenObject, + symbolObject, + customPrototypeObject, + forgedArray, + spoofedObject, + revokedObject, + spoofedArray, + cyclic, + [undefined], + { value: undefined }, + ]) { + const canonical = snapshotJsonValue(value) + expect(canonical).toBeUndefined() + expect(snapshotCodeJsonValue(value)).toEqual(canonical) + } + }) + + it('rejects an array whose getter mutates the validated length', () => { + const array = [0, 2] + Object.defineProperty(array, 0, { + enumerable: true, + get: () => { + array.length = 1 + return 1 + }, + }) + + expect(snapshotCodeJsonValue(array)).toBeUndefined() + }) + + it('propagates a throwing getter and releases its recursion guard', () => { + const failure = new Error('getter failed') + const source = Object.defineProperty({}, 'value', { + enumerable: true, + get: () => { throw failure }, + }) + + expect(() => snapshotCodeJsonValue(source)).toThrow(failure) + expect(snapshotCodeJsonValue({ after: true })).toEqual({ after: true }) + }) +}) + +describe('flat worker JSON wire', () => { + it('round-trips every JSON root while preserving object keys and container order', () => { + const withPrototypeKey = Object.create(null) as Record + withPrototypeKey.__proto__ = { safe: true } + const values = [null, false, true, 1.25, 'text', [], {}, [1, { nested: [2] }], withPrototypeKey] + for (const value of values) { + const snapshot = snapshotCodeJsonValue(value) + expect(snapshot).not.toBeUndefined() + expect(decodeWorkerJson(encodeWorkerJson(snapshot!))).toEqual(snapshot) + } + const decoded = decodeWorkerJson(encodeWorkerJson(snapshotCodeJsonValue(withPrototypeKey)!)) as Record + expect(Object.hasOwn(decoded, '__proto__')).toBe(true) + expect(decoded.__proto__).toEqual({ safe: true }) + }) + + it('round-trips deep values through a bounded-depth token array', () => { + let value: unknown = 'leaf' + for (let depth = 0; depth < 5_000; depth++) value = [value] + const snapshot = snapshotCodeJsonValue(value)! + const wire = encodeWorkerJson(snapshot) + expect(wire).toHaveLength(5_001) + + let cursor = decodeWorkerJson(wire) + for (let depth = 0; depth < 5_000; depth++) { + expect(Array.isArray(cursor)).toBe(true) + cursor = Array.isArray(cursor) ? cursor[0] : undefined + } + expect(cursor).toBe('leaf') + }) + + it('rejects malformed, incomplete, lossy, sparse, decorated, and throwing wire values', () => { + const sparse = new Array(1) + const compensatedSparse = new Array(1) + Object.defineProperty(compensatedSparse, 'extra', { value: true }) + const decorated: unknown[] = [null] + Object.defineProperty(decorated, 'extra', { value: true }) + const throwing: unknown[] = [] + Object.defineProperty(throwing, 0, { enumerable: true, get: () => { throw new Error('wire getter') } }) + const decoratedKeys: unknown[] = ['x'] + Object.defineProperty(decoratedKeys, 'extra', { value: true }) + const foreignMarker: Record = { kind: 'array', length: 0 } + Object.setPrototypeOf(foreignMarker, {}) + const hiddenMarker = Object.defineProperty({ kind: 'array', length: 0 }, 'hidden', { value: true }) + + for (const value of [ + undefined, + null, + {}, + [], + sparse, + compensatedSparse, + decorated, + throwing, + [undefined], + [-0], + [Number.NaN], + [Number.POSITIVE_INFINITY], + [1, 2], + [[]], + [foreignMarker], + [hiddenMarker], + [{ kind: 'unknown' }], + [{ kind: 'array', bogus: 0 }], + [{ kind: 'array' }], + [{ kind: 'array', length: '1' }], + [{ kind: 'array', length: -1 }], + [{ kind: 'array', length: Number.MAX_SAFE_INTEGER + 1 }], + [{ kind: 'array', length: 1 }], + [{ kind: 'array', length: 2 }, { kind: 'array', length: 1 }, null], + [{ kind: 'array', length: 0, extra: true }], + [{ kind: 'object' }], + [{ kind: 'object', keys: 'x' }], + [{ kind: 'object', keys: decoratedKeys }], + [{ kind: 'object', keys: [1] }], + [{ kind: 'object', keys: ['x', 'x'] }, 1, 2], + [{ kind: 'object', keys: ['x'] }], + [{ kind: 'object', keys: [], extra: true }], + ]) { + expect(decodeWorkerJson(value)).toBeUndefined() + } + }) + + it('rejects invalid values passed through a forged static type', () => { + expect(() => encodeWorkerJson([undefined] as never)).toThrow(/sparse JSON array/) + expect(() => encodeWorkerJson({ value: undefined } as never)).toThrow(/undefined JSON object property/) + }) +}) diff --git a/packages/code-runtime/code-runtime-worker/tsconfig.json b/packages/code-runtime/code-runtime-worker/tsconfig.json index 4a201c70e1..b739c7c3bc 100644 --- a/packages/code-runtime/code-runtime-worker/tsconfig.json +++ b/packages/code-runtime/code-runtime-worker/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../core/session" + }, { "path": "../../../vendor/cosmokit" }, diff --git a/packages/code-runtime/code-runtime/README.md b/packages/code-runtime/code-runtime/README.md index b31af71397..f8e09e301e 100644 --- a/packages/code-runtime/code-runtime/README.md +++ b/packages/code-runtime/code-runtime/README.md @@ -8,15 +8,15 @@ This package is the interface third of the capability (the bash trio is the temp | Member | Semantics | |---|---| -| `run(request)` | Execute one program against the request's bindings. **Resolves with an error FIELD for every program outcome** — parse/transform failure, thrown exception, budget expiry, abort, substrate death (`CodeRunFailure`'s orthogonal `kind` taxonomy); it rejects only for caller misuse of the seam itself (e.g. a run submitted after disposal). The program runs as the body of an async function: top-level `await`/`return` work, and the completion value becomes `result.value` when it survives the serialization boundary. | +| `run(request)` | Execute one program against the request's bindings. **Resolves with an error FIELD for every program outcome** — parse/transform failure, thrown exception, invalid completion, output overflow, budget expiry, abort, or substrate death (`CodeRunFailure`'s orthogonal `kind` taxonomy); it rejects only for caller misuse of the seam itself (e.g. a run submitted after disposal). The program runs as the body of an async function: top-level `await`/`return` work, and a lossless JSON completion becomes `result.value`. | | `language` | Readonly descriptor: the source language `run` expects (`'typescript'` is the well-known value). Informational, not gating — a consumer that generates language-specific presentation switches on it and fails loud on a language it cannot present. | | `isolation` | Readonly descriptor: the execution substrate (`'worker-thread'`, `'process'`, `'container'`). A label for deployments and diagnostics, **not a security claim**. | -Semantics every implementation must honor (contract details in the class JSDoc): binding calls bridge to the caller's functions verbatim with structured-cloneable arguments/resolutions; the program is treated as a hostile peer (arbitrary binding names are own properties, malformed traffic never crashes the host); no state survives between runs; disposal terminates in-flight runs AND awaits their exit before completing. +Semantics every implementation must honor (contract details in the class JSDoc): binding calls bridge complete lossless-JSON arguments and resolutions with no seam-level byte cap; the program is treated as a hostile peer (arbitrary binding names are own properties, malformed traffic never crashes the host); no state survives between runs; disposal terminates in-flight runs AND awaits their exit before completing. ## Vocabulary -`CodeRunRequest` (`program`, `bindings`, `signal?`) carries everything the runtime acts on — defaulting (time budgets, output caps) is the implementation's validated config, never a hidden `??` inside `run()`. `bindings` is a list of `CodeBindingNamespace`s (`global` + `functions`), each exposed to the program as one global object of async callables. `CodeRunResult` reports the completion `value?`, ordered capped `logs: string[]`, and the `error?` (`CodeRunFailure`: `kind` + model-feedable `message`). See `src/types.ts` for the full contracts. +`CodeRunRequest` (`program`, `bindings`, `signal?`) carries everything the runtime acts on — defaulting (time budgets and outer-output cap) is the implementation's validated config, never a hidden `??` inside `run()`. `bindings` is a list of `CodeBindingNamespace`s (`global` + `functions` + optional `errorClass`), each exposed to the program as one global object of async callables returning `CodeJsonValue`, the seam-local structural equivalent of canonical `JsonValue` that keeps this interface package independent of sessions. An `errorClass` descriptor names a real program-global constructor and the own property that receives the rejected member name; runtimes remain independent of consumer terms such as `ToolCallError`. `CodeRunResult` reports the lossless JSON completion `value?`, ordered `logs: string[]`, and the `error?` (`CodeRunFailure`: `kind` + model-feedable `message`). See `src/types.ts` for the full contracts. ## Model Experience @@ -31,3 +31,4 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **`run()` is one-shot** — `logs` arrive only on the resolved `CodeRunResult`; the seam exposes no streaming-log or progress surface for a live program's output. - **A persistent REPL-style kernel is recorded future work** — the no-state-between-runs contract stands until a persistent-kernel backend brings its own logging story ([Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)). - **Only the worker-thread backend ships** — `'process'`/`'container'` are declared well-known `isolation` values with no implementation; a hard security boundary awaits a container backend. +- **Intermediate binding values have no byte cap** — implementations remain subject to structured-clone cost and process memory, while a provider or executor may already have imposed its own acquisition bound. diff --git a/packages/code-runtime/code-runtime/src/index.ts b/packages/code-runtime/code-runtime/src/index.ts index bd8efe1377..bd52b9ed29 100644 --- a/packages/code-runtime/code-runtime/src/index.ts +++ b/packages/code-runtime/code-runtime/src/index.ts @@ -8,8 +8,10 @@ import { Context, Service } from 'cordis' import type { CodeRunRequest, CodeRunResult } from './types.ts' export type { + CodeBindingErrorClass, CodeBindingFunction, CodeBindingNamespace, + CodeJsonValue, CodeRunFailure, CodeRunRequest, CodeRunResult, @@ -24,8 +26,9 @@ declare module 'cordis' { /** * Registers one `ctx.codeRuntime` implementation. Program, budget, abort, and substrate * failures resolve in {@link CodeRunResult}; only seam misuse rejects. Implementations bridge - * structured-cloneable bindings while treating programs as hostile peers, isolate runs from - * one another, and terminate and await in-flight runs during disposal. + * structured-cloneable bindings, materialize each declared namespace rejection + * class, treat programs as hostile peers, isolate runs from one another, and + * terminate and await in-flight runs during disposal. */ export abstract class CodeRuntime extends Service { /** diff --git a/packages/code-runtime/code-runtime/src/types.ts b/packages/code-runtime/code-runtime/src/types.ts index d7669a4785..a53353799b 100644 --- a/packages/code-runtime/code-runtime/src/types.ts +++ b/packages/code-runtime/code-runtime/src/types.ts @@ -9,12 +9,30 @@ /** * One host-side function exposed to the program as an async callable. The * runtime bridges calls to it (possibly across a serialization boundary), so - * `args` and the resolution value MUST be structured-cloneable; a runtime - * rejects a non-cloneable value with a descriptive error rather than - * corrupting the run. A rejection of this function surfaces inside the - * program as a rejection of the corresponding call. + * `args` and the resolution value MUST be lossless JSON. A runtime rejects a + * lossy or non-cloneable value with a descriptive error rather than corrupting + * the run. No seam-level byte cap applies to a binding resolution. A rejection + * of this function surfaces inside the program as a rejection of the + * corresponding call. */ -export type CodeBindingFunction = (args: unknown) => Promise +export type CodeBindingFunction = (args: unknown) => Promise + +/** A lossless JSON value transferable across the dependency-light code-runtime seam. */ +export type CodeJsonValue = null | boolean | number | string | CodeJsonValue[] | { [key: string]: CodeJsonValue } + +/** + * Program-visible typed rejection for one binding namespace. The runtime + * injects a real error constructor under `name`; rejected member calls become + * its instances and expose the exact member name through + * `memberNameProperty`. Both strings are runtime data rather than knowledge + * of a particular consumer such as Code Mode. + */ +export interface CodeBindingErrorClass { + /** Constructor global and resulting `Error.name` (must be a usable JS identifier). */ + name: string + /** Non-empty own property for the member name; cannot replace `name`, `message`, or `stack`. */ + memberNameProperty: string +} /** * A named group of {@link CodeBindingFunction}s the runtime exposes to the @@ -28,6 +46,8 @@ export interface CodeBindingNamespace { global: string /** The callable members, keyed by the exact name the program calls. */ functions: Record + /** Optional program-visible typed rejection contract for this namespace. */ + errorClass?: CodeBindingErrorClass } /** @@ -63,10 +83,12 @@ export interface CodeRunRequest { * - `'timeout'` — an implementation-owned budget expired; the message says which. * - `'abort'` — {@link CodeRunRequest.signal} fired. * - `'worker-exit'` — the execution substrate died without settling (e.g. OOM). + * - `'invalid-output'` — the completion value was not lossless JSON. + * - `'output-limit'` — the serialized outer logs/value/diagnostic exceeded the configured cap. */ export interface CodeRunFailure { /** The failure class (see the interface doc for each kind's meaning). */ - kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' + kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' | 'invalid-output' | 'output-limit' /** Human-readable detail, suitable for feeding back to a model to self-correct. */ message: string } @@ -79,12 +101,12 @@ export interface CodeRunFailure { export interface CodeRunResult { /** * The program's completion value (its top-level `return`), when it ran to - * completion and the value survived the runtime's serialization boundary; - * a non-transferable value is replaced by a string rendering, and a failed - * or value-less run leaves this absent. + * completion and the value crossed the runtime's lossless-JSON boundary. + * Invalid or over-limit completions fail the run instead of substituting a + * rendered string; a failed or value-less run leaves this absent. */ - value?: unknown - /** Text the program emitted, in order (capped by the implementation). */ + value?: CodeJsonValue + /** Text the program emitted, in order, bounded only as part of the outer result. */ logs: string[] /** Present iff the run failed; see {@link CodeRunFailure} for the taxonomy. */ error?: CodeRunFailure diff --git a/packages/code-runtime/code-runtime/tests/service.spec.ts b/packages/code-runtime/code-runtime/tests/service.spec.ts index 56a32930c9..605b51934f 100644 --- a/packages/code-runtime/code-runtime/tests/service.spec.ts +++ b/packages/code-runtime/code-runtime/tests/service.spec.ts @@ -45,7 +45,7 @@ describe('CodeRuntime service seam', () => { const calls: unknown[] = [] const result = await runtime.run({ program: 'return 1', - bindings: [{ global: 'tools', functions: { probe: async args => void calls.push(args) } }], + bindings: [{ global: 'tools', functions: { probe: async (args) => { calls.push(args); return null } } }], }) expect(result).toEqual({ logs: [] }) expect(calls).toEqual([{ from: 'stub' }]) 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 4b3dd7a6b3..f9f61e4096 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' @@ -134,7 +134,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TokenMeterService) ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps)) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'work', description: 'does work', parameters: { i: { type: 'number' } }, diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index d1cd07207e..a42ed71454 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -5,7 +5,7 @@ import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' -import { defineTool } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as timeContext from '@deepseek-ai/dsh-time-context' @@ -386,7 +386,7 @@ describe('real agent-loop request history', () => { it('persists one ordered context per request, accumulates readings, and leaves system headers unchanged', async () => { const adapter = new ScriptedAdapter([toolCallResponse(), textResponse('done')]) const ctx = await loopHarness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'tick', description: 'advance fake time', parameters: {}, diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts index d06c8c542f..5bd858a8a0 100644 --- a/packages/context/workspace-context/src/index.ts +++ b/packages/context/workspace-context/src/index.ts @@ -129,8 +129,7 @@ export function apply(ctx: Context, config: Config): void { if (update === undefined) return downstream pendingVersionUpdates.set(exec.token, update.versionUpdates) return { - kind: 'accept', - ...downstream.content !== undefined ? { content: downstream.content } : {}, + ...downstream, additionalContexts: [update.context, ...downstream.additionalContexts ?? []], } }) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index c26be1a58e..475db4b479 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -22,8 +22,11 @@ import type { } from '@deepseek-ai/dsh-fs' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import type { ToolExecution, ToolExecutionToken } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import type { + ToolExecution, + ToolExecutionToken, +} from '@deepseek-ai/dsh-tools' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import { discoverBaselineInstructionFiles, @@ -864,6 +867,7 @@ describe('workspace context request injection', () => { agent: stubAgent('/virtual/repo'), }), { isError: false, + value: null, content: [{ type: 'text', text: 'file content' }], }, async () => ({ kind: 'accept', @@ -900,7 +904,8 @@ describe('workspace context request injection', () => { agent, }) const result = { - isError: false, + isError: false as const, + value: null, content: [{ type: 'text' as const, text: 'hello' }], } @@ -1699,7 +1704,7 @@ describe('dynamic nested workspace context injection', () => { await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('workspace-context-abort'), { provider: 'mock', model: 'mock' }, { cwd: root }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'abort_step', description: 'Abort the current test step.', parameters: {}, @@ -1770,6 +1775,7 @@ describe('dynamic nested workspace context injection', () => { const pending = ctx.waterfall('tools/post-execute', exec, { content: [{ type: 'text', text: 'ok' }], isError: false, + value: null, }, () => Promise.resolve({ kind: 'accept' as const })) await expect(pending).rejects.toBe(reason) @@ -2777,7 +2783,8 @@ describe('dynamic nested workspace context injection', () => { const result = { callId: CallId('provider-probe-result'), content: [{ type: 'text' as const, text: 'ok' }], - isError: false, + isError: false as const, + value: null, } const failedStat = await ctx.waterfall('tools/post-execute', stubToolExecution({ @@ -2837,7 +2844,7 @@ describe('dynamic nested workspace context injection', () => { } }) - it('preserves nested and downstream post-execute contexts as separate entries', async () => { + it('preserves a downstream canonical value replacement and keeps contexts separate', async () => { const root = await tempRepo() const home = await tempRepo() try { @@ -2848,7 +2855,12 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, - content: [{ type: 'text' as const, text: 'downstream replacement' }], + value: { + path: 'pkg/deep/file.txt', + offset: 1, + lines: [{ number: 1, text: 'downstream replacement' }], + totalLines: 1, + }, additionalContexts: [{ content: [{ type: 'text' as const, text: 'downstream context' }], source: { kind: 'plugin' as const, plugin: 'downstream' }, @@ -2863,7 +2875,15 @@ describe('dynamic nested workspace context injection', () => { agent: stubAgent(root), }) - expect(blocksText(result.content)).toBe('downstream replacement') + expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected read replacement success') + expect(result.value).toEqual({ + path: 'pkg/deep/file.txt', + offset: 1, + lines: [{ number: 1, text: 'downstream replacement' }], + totalLines: 1, + }) + expect(blocksText(result.content)).toContain('downstream replacement') expect(result.additionalContexts).toHaveLength(2) expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' }) expect(workspaceContextOf(result)?.meta).toMatchObject({ @@ -2980,7 +3000,7 @@ describe('dynamic nested workspace context injection', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(ToolFs) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'composite-read', description: 'read through a nested dispatch', parameters: {}, @@ -3035,7 +3055,7 @@ describe('dynamic nested workspace context injection', () => { await ctx.plugin(workspaceContext, { maxBytes: 65536 }) const agent = stubAgent('/') const parent = Symbol('parent') as ToolExecutionToken - const plainResult = { callId: CallId('plain'), content: [], isError: false } + const plainResult = { callId: CallId('plain'), content: [], isError: false as const, value: null } ctx.emit('tools/result', stubToolExecution({ signal: testToolSignal, @@ -3077,7 +3097,8 @@ describe('dynamic nested workspace context injection', () => { const result = { callId: CallId('manual'), content: [{ type: 'text' as const, text: 'manual result' }], - isError: false, + isError: false as const, + value: null, } const cases = [ { name: 'read', arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent: undefined }, diff --git a/packages/cordis/tool-cordis/README.md b/packages/cordis/tool-cordis/README.md index 7819cb8c2c..aabc893808 100644 --- a/packages/cordis/tool-cordis/README.md +++ b/packages/cordis/tool-cordis/README.md @@ -10,9 +10,11 @@ The self-referential cordis toolset: three model-facing tools over the live runt Exact model-facing schemas: [the generated tool catalog](../../../docs/tool-catalog.md). +Canonical successes are the inspection string, mount `{ id, pluginName, state, provides, waitingFor }`, and unmount `{ id, pluginName }`. Native renderers preserve the existing prose, so programs can use `mounted.id` while ordinary function calling still sees `mounted dyn-1 (...)`. + ## Trust stance -The sandbox isolates globals but is not a security boundary. Node globals are absent or redirect to Cordis services such as `ctx.fs`, `ctx.web`, and `ctx.bash`, and writes to `globalThis` stay local, but host-realm helpers make escape possible. Mounted plugins receive a façade without framework internals, yet its allowed services affect the live runtime. Treat this toolset like bash access; see the [design and trust stance](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). +The sandbox isolates globals but is not a security boundary. Node globals are absent or redirect to Cordis services such as `ctx.fs`, `ctx.web`, and `ctx.bash`, and writes to `globalThis` stay local, but host-realm helpers make escape possible. Mounted plugins receive a façade without framework internals, yet its allowed services affect the live runtime. Dynamic tool schemas and annotations cross the realm through iterative JSON cloning and schema normalization, so valid deep declarations are memory-bounded rather than call-stack-bounded; records with JSON-invisible keys and subclassed or decorated schema arrays reject before normalization. Treat this toolset like bash access; see the [design and trust stance](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). ## Config diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index d8614a9e87..5f60c9066e 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1253,17 +1253,25 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CallId', declaration: 'export type CallId = Branded<\'CallId\'>;', }, + { + name: 'CodeBindingErrorClass', + declaration: 'export interface CodeBindingErrorClass {\n name: string;\n memberNameProperty: string;\n}', + }, { name: 'CodeBindingFunction', - declaration: 'export type CodeBindingFunction = (args: unknown) => Promise;', + declaration: 'export type CodeBindingFunction = (args: unknown) => Promise;', }, { name: 'CodeBindingNamespace', - declaration: 'export interface CodeBindingNamespace {\n global: string;\n functions: Record;\n}', + declaration: 'export interface CodeBindingNamespace {\n global: string;\n functions: Record;\n errorClass?: CodeBindingErrorClass;\n}', + }, + { + name: 'CodeJsonValue', + declaration: 'export type CodeJsonValue = null | boolean | number | string | CodeJsonValue[] | {\n [key: string]: CodeJsonValue;\n};', }, { name: 'CodeRunFailure', - declaration: 'export interface CodeRunFailure {\n kind: \'exception\' | \'timeout\' | \'abort\' | \'worker-exit\';\n message: string;\n}', + declaration: 'export interface CodeRunFailure {\n kind: \'exception\' | \'timeout\' | \'abort\' | \'worker-exit\' | \'invalid-output\' | \'output-limit\';\n message: string;\n}', }, { name: 'CodeRunRequest', @@ -1271,7 +1279,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CodeRunResult', - declaration: 'export interface CodeRunResult {\n value?: unknown;\n logs: string[];\n error?: CodeRunFailure;\n}', + declaration: 'export interface CodeRunResult {\n value?: CodeJsonValue;\n logs: string[];\n error?: CodeRunFailure;\n}', }, { name: 'CollectedOutput', @@ -1473,6 +1481,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'InvariantInstaller', declaration: 'export interface InvariantInstaller {\n (ctx: Context, fail: InvariantFailure): void | Promise;\n readonly inject?: Inject;\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};', @@ -1509,6 +1529,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'MessageSourceMap', declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n plugin: {\n kind: \'plugin\';\n plugin: string;\n };\n}', }, + { + name: 'ObjectJsonSchema', + declaration: 'export type ObjectJsonSchema = JsonSchemaNode & {\n type: \'object\';\n};', + }, { name: 'OutOfBandSessionEventMap', declaration: 'export interface OutOfBandSessionEventMap {\n}', @@ -1679,7 +1703,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionEventMap', - declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': PromptMessageData;\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n };\n \'steering/message\': PromptMessageData & {\n turn: number;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: Req /* …truncated — full shape in source */', + declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': PromptMessageData;\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'steering/message\': PromptMessageData & {\n turn: number;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: R /* …truncated — full shape in source */', }, { name: 'SessionEventReadRequest', @@ -1841,22 +1865,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'StreamChunk', declaration: 'export type StreamChunk = {\n type: \'block-start\';\n index: number;\n blockType: ContentBlockType;\n} | {\n type: \'text-delta\';\n index: number;\n text: string;\n} | {\n type: \'reasoning-delta\';\n index: number;\n text: string;\n} | {\n type: \'tool-call-delta\';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n} | {\n type: \'block-end\';\n index: number;\n block: ContentBlock;\n} | {\n type: \'usage\';\n usage: TokenUsage;\n} | {\n type: \'finish\';\n reason: FinishReason;\n replayState?: unknown;\n};', }, - { - name: 'StructuredOutputSchema', - declaration: 'export type StructuredOutputSchema = StructuredSchemaNode & {\n type: \'object\';\n};', - }, - { - name: 'StructuredScalar', - declaration: 'export type StructuredScalar = string | number | boolean | null;', - }, - { - name: 'StructuredSchemaNode', - declaration: 'export interface StructuredSchemaNode {\n type: StructuredSchemaType;\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: StructuredSchemaNode;\n enum?: StructuredScalar[];\n const?: StructuredScalar;\n description?: string;\n title?: string;\n default?: unknown;\n examples?: unknown;\n}', - }, - { - name: 'StructuredSchemaType', - declaration: 'export type StructuredSchemaType = \'object\' | \'array\' | \'string\' | \'number\' | \'integer\' | \'boolean\' | \'null\';', - }, { name: 'SubagentCapabilities', declaration: 'export interface SubagentCapabilities {\n readonly outputSchema: boolean;\n readonly depthLimit: boolean;\n readonly toolFilter: boolean;\n readonly persona: boolean;\n}', @@ -1875,7 +1883,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentStartRequest', - declaration: 'export interface SubagentStartRequest {\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly agentOptions?: AgentOptions;\n readonly outputSchema?: StructuredOutputSchema;\n readonly maxDepth?: number;\n readonly toolFilter?: ToolRestriction;\n readonly persona?: string;\n}', + declaration: 'export interface SubagentStartRequest {\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly agentOptions?: AgentOptions;\n readonly outputSchema?: ObjectJsonSchema;\n readonly maxDepth?: number;\n readonly toolFilter?: ToolRestriction;\n readonly persona?: string;\n}', }, { name: 'SubagentStopReason', @@ -1979,20 +1987,20 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolDefinition', - declaration: 'export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\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 readonly signal: AbortSignal;\n}', @@ -2003,16 +2011,28 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolExecutionResult', - declaration: 'export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n}', + declaration: 'export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;', + }, + { + name: 'ToolExecutionSuccess', + declaration: 'export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n}', }, { name: 'ToolExecutionToken', declaration: 'export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n};', }, + { + name: 'ToolFailure', + declaration: 'export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n}', + }, { name: 'ToolGuard', declaration: 'export type ToolGuard = (execution: Readonly) => string | undefined;', }, + { + name: 'ToolOutputDefinition', + declaration: 'export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n}', + }, { name: 'ToolProviderResult', declaration: 'export interface ToolProviderResult {\n readonly schemas: readonly ToolSchema[];\n readonly knownNames?: readonly string[];\n}', @@ -2023,7 +2043,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolResult', - declaration: 'export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n}', + declaration: 'export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n}', }, { name: 'ToolResultBlock', 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 2198533376..b9bc992ad8 100644 --- a/packages/cordis/tool-cordis/src/guard.ts +++ b/packages/cordis/tool-cordis/src/guard.ts @@ -1,13 +1,13 @@ /** - * The registration boundary between sandboxed mount code and the real runtime: SchemaSpec + * The registration boundary between sandboxed mount code and the real runtime: ParameterSchemaSpec * normalization + validation with teaching errors, the marker-guarded `harness.defineTool` / * `harness.registerTool` pair, the SANDBOX CONTEXT FAÇADE a mounted plugin's `apply` receives * in place of the real `ctx`, and the plugin-shape helpers the mount lifecycle narrows sandbox * return values with. The façade is a whitelist of lifecycle-safe verbs and declared services; * framework internals and context-valued service returns are denied. * - * VM-realm schemas are rebuilt as host objects, and tool results are JSON-round-tripped and - * shape-checked before session logging. Common JSON-Schema spellings are normalized when they + * VM-realm schemas and canonical values are rebuilt as host objects, while rendered content and + * presentation metadata are shape-checked before entering the registry. Common JSON-Schema spellings are normalized when they * have one meaning; invalid vocabulary fails during registration with a teaching error. * @module @deepseek-ai/dsh-tool-cordis/guard */ @@ -15,84 +15,470 @@ import { Context } from 'cordis' import type { Plugin } from 'cordis' import { scopeOf } from '@deepseek-ai/dsh-scope' -import { defineTool } from '@deepseek-ai/dsh-tools' -import type { ToolDefinition, ToolExecuteReturn } from '@deepseek-ai/dsh-tools' +import { assertSupportedJsonSchema, defineTool } from '@deepseek-ai/dsh-tools' +import type { ToolDefinition } from '@deepseek-ai/dsh-tools' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { JsonValue } from '@deepseek-ai/dsh-session' const DYNAMIC_TOOL = Symbol('tool-cordis.dynamic-tool') -const SCHEMA_TYPES = new Set(['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 } function isPlainRecord(value: unknown): value is Record { - return Object.prototype.toString.call(value) === '[object Object]' + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const prototype: unknown = Object.getPrototypeOf(value) + return prototype === null + || typeof prototype === 'object' + && Object.getPrototypeOf(prototype) === null + && hasIntrinsicConstructor(prototype, 'Object') +} + +/* jscpd:ignore-start -- this VM boundary mirrors the session-owned realm-safe intrinsic test */ +/** Whether a realm-owned intrinsic prototype is backed by its native constructor. */ +function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): boolean { + const descriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor') + const constructor: unknown = descriptor?.value + if (typeof constructor !== 'function') return false + try { + return constructor.name === name + && constructor.prototype === prototype + && Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }` + } catch { + return false + } +} + +/** Whether an array uses one realm's intrinsic Array prototype rather than a subclass. */ +function hasPlainArrayPrototype(value: unknown[]): boolean { + const prototype: unknown = Object.getPrototypeOf(value) + if (!Array.isArray(prototype) || !hasIntrinsicConstructor(prototype, 'Array')) return false + const objectPrototype: unknown = Object.getPrototypeOf(prototype) + return typeof objectPrototype === 'object' + && objectPrototype !== null + && Object.getPrototypeOf(objectPrototype) === null + && hasIntrinsicConstructor(objectPrototype, 'Object') +} +/* jscpd:ignore-end */ + +/** Whether a schema list is a dense intrinsic array with no JSON-invisible decorations. */ +function isDensePlainArray(value: unknown): value is unknown[] { + if (!Array.isArray(value) || !hasPlainArrayPrototype(value) || Reflect.ownKeys(value).length !== value.length + 1) { + return false + } + for (let index = 0; index < value.length; index++) { + if (!Object.hasOwn(value, index)) return false + } + return true +} + +/** Reject schema records whose declarations would disappear from object enumeration. */ +function assertSchemaContainerKeys(value: Record, path: string): void { + if (Reflect.ownKeys(value).some(key => typeof key !== 'string' || !Object.prototype.propertyIsEnumerable.call(value, key))) { + throw new Error(`harness.defineTool ${path} must contain only own enumerable string keys`) + } +} + +/** Where one cloned JSON value is installed. */ +type CloneDestination = + | { kind: 'root' } + | { kind: 'array'; target: unknown[]; index: number } + | { kind: 'object'; target: Record; key: string } + +/** Deferred work for stack-safe cross-realm JSON cloning. */ +type CloneTask = + | { kind: 'visit'; value: unknown; path: string; destination: CloneDestination } + | { kind: 'array-item'; source: unknown[]; index: number; path: string; target: unknown[] } + | { kind: 'leave'; source: object } + +/** Materialize realm-foreign lossless JSON without allowing JSON.stringify coercions. */ +function cloneJson(value: unknown, path: string): unknown { + const ancestors = new Set() + let root: unknown + const assign = (destination: CloneDestination, item: unknown): void => { + if (destination.kind === 'root') { + root = item + return + } + if (destination.kind === 'array') { + destination.target[destination.index] = item + return + } + Object.defineProperty(destination.target, destination.key, { + value: item, + enumerable: true, + configurable: true, + writable: true, + }) + } + const reject = (at: string): never => { + throw new Error(`harness.defineTool ${at} must be lossless JSON data`) + } + + const tasks: CloneTask[] = [{ kind: 'visit', value, path, destination: { kind: 'root' } }] + for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) { + if (task.kind === 'leave') { + ancestors.delete(task.source) + continue + } + if (task.kind === 'array-item') { + if (!Object.hasOwn(task.source, task.index)) reject(task.path) + tasks.push({ + kind: 'visit', + value: task.source[task.index], + path: `${task.path}[${task.index}]`, + destination: { kind: 'array', target: task.target, index: task.index }, + }) + continue + } + + const current = task.value + if (current === null || typeof current === 'string' || typeof current === 'boolean') { + assign(task.destination, current) + continue + } + if (typeof current === 'number') { + if (!Number.isFinite(current) || Object.is(current, -0)) reject(task.path) + assign(task.destination, current) + continue + } + if (typeof current !== 'object' || ancestors.has(current)) reject(task.path) + + if (Array.isArray(current)) { + if (!hasPlainArrayPrototype(current) || Reflect.ownKeys(current).length !== current.length + 1) reject(task.path) + const output: unknown[] = [] + assign(task.destination, output) + ancestors.add(current) + tasks.push({ kind: 'leave', source: current }) + for (let index = current.length - 1; index >= 0; index--) { + tasks.push({ kind: 'array-item', source: current, index, path: task.path, target: output }) + } + continue + } + if (!isPlainRecord(current)) reject(task.path) + const record = current as Record + if (Reflect.ownKeys(record).some(key => typeof key !== 'string' || !Object.prototype.propertyIsEnumerable.call(record, key))) { + reject(task.path) + } + const output: Record = {} + assign(task.destination, output) + ancestors.add(record) + tasks.push({ kind: 'leave', source: record }) + const entries = Object.entries(record) + for (let index = entries.length - 1; index >= 0; index--) { + const entry = entries[index] + /* v8 ignore next -- the loop is bounded by the captured entry count. */ + if (entry === undefined) continue + tasks.push({ + kind: 'visit', + value: entry[1], + path: `${task.path}.${entry[0]}`, + destination: { kind: 'object', target: output, key: entry[0] }, + }) + } + } + return root +} + +/** Copy and realm-materialize the shared annotation vocabulary. */ +function copyAnnotations(value: Record, 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 { + assertSchemaContainerKeys(value, path) + for (const key of Object.keys(value)) { + if (!allowed.includes(key)) throw new Error(`harness.defineTool ${path}.${key} is not supported by the unified schema DSL`) + } } /** * Normalize a sandbox-provided `parameters` value into a fresh host-realm - * SchemaSpec. Accepts the DSL directly, or the JSON-Schema-style - * `{ type: 'object', properties, required: […] }` wrapper models write by - * prior — the wrapper unwraps and its `required` array becomes per-property - * flags (see the module doc). + * ParameterSchemaSpec. A raw JSON-Schema object wrapper retains its open root + * default, while the direct DSL is already an implicit open property map. */ -function normalizeSchemaSpec(value: unknown, path = 'parameters'): Record { +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 } - const spec: Record = {} - for (const [key, prop] of Object.entries(entries)) { - spec[key] = normalizeSchemaProp(prop, `${path}.${key}`, requiredNames.has(key)) - } - return spec + return { spec: normalizePropertyMap(value, path, new Set(), false) } } -/** Normalize one property: `integer` → `number`, `required: false` → absent, nested wrappers unwrapped recursively. */ -function normalizeSchemaProp(value: unknown, path: string, forceRequired = false): Record { - if (!isPlainRecord(value)) { - throw new Error(`harness.defineTool ${path} must be a SchemaSpec property object`) +/** Validate raw required names and return their lookup set. */ +function normalizeRequiredNames(value: unknown, properties: Record, path: string): Set { + if (value === undefined) return new Set() + if (!isDensePlainArray(value)) { + throw new Error(`harness.defineTool ${path} must be an array of declared property names`) } - const type = value.type === 'integer' ? 'number' : value.type - if (!SCHEMA_TYPES.has(type)) { - throw new Error(`harness.defineTool ${path} must declare a valid type: ${VALID_TYPES} (got ${JSON.stringify(value.type)})`) - } - // On an object property a JSON-Schema-style `required` ARRAY names required - // children (handled by the nested unwrap below); everywhere else `required` - // must be a boolean, and `false` means optional. - const nestedRequiredArray = type === 'object' && Array.isArray(value.required) - if (value.required !== undefined && typeof value.required !== 'boolean' && !nestedRequiredArray) { - throw new Error(`harness.defineTool ${path}.required must be a boolean when present`) - } - const prop: Record = { type } - if (forceRequired || value.required === true) prop.required = true - if (typeof value.description === 'string') prop.description = value.description - if (Array.isArray(value.enum)) prop.enum = [...value.enum as unknown[]] - if (value.default !== undefined) prop.default = value.default - if (value.properties !== undefined) { - if (type !== 'object') { - throw new Error(`harness.defineTool ${path}.properties is only valid for type "object"`) + const names = new Set() + for (let index = 0; index < value.length; index++) { + const name = value[index] + if (typeof name !== 'string') { + throw new Error(`harness.defineTool ${path} must be an array of declared property names`) } - // Re-wrap so the nested unwrap applies a nested `required` array too. - prop.properties = normalizeSchemaSpec( - { type: 'object', properties: value.properties, required: value.required }, - `${path}.properties`, - ) + names.add(name) + if (!Object.hasOwn(properties, name)) throw new Error(`harness.defineTool ${path} names undeclared property ${JSON.stringify(name)}`) } - if (value.items !== undefined) { - if (type !== 'array') { - throw new Error(`harness.defineTool ${path}.items is only valid for type "array"`) + return names +} + +/** Mutable holder used only while one normalized property-map root is unresolved. */ +interface NormalizeRoot { + value?: Record +} + +/** Where a normalized value node is installed. */ +type NormalizeValueDestination = + | { kind: 'property'; target: Record; key: string } + | { kind: 'item'; target: Record } + | { kind: 'one-of'; target: Record[]; index: number } + +/** Where a normalized property map is installed. */ +type NormalizeMapDestination = + | { kind: 'root'; holder: NormalizeRoot } + | { kind: 'properties'; target: Record } + +/** Deferred work for stack-safe sandbox schema normalization. */ +type NormalizeTask = + | { + kind: 'map' + entries: Record + path: string + requiredNames: ReadonlySet + raw: boolean + destination: NormalizeMapDestination + } + | { + kind: 'value' + value: unknown + path: string + forceRequired: boolean + raw: boolean + parameterProperty: boolean + destination: NormalizeValueDestination + } + | { kind: 'leave'; value: object } + +/** Install one normalized node without `__proto__` assignment semantics. */ +function assignNormalizedValue(destination: NormalizeValueDestination, value: Record): void { + if (destination.kind === 'property') { + Object.defineProperty(destination.target, destination.key, { + value, + enumerable: true, + configurable: true, + writable: true, + }) + } else if (destination.kind === 'item') { + destination.target.items = value + } else { + destination.target[destination.index] = value + } +} + +/** Install one normalized property map at its root or containing object. */ +function assignNormalizedMap(destination: NormalizeMapDestination, value: Record): void { + if (destination.kind === 'root') destination.holder.value = value + else destination.target.properties = value +} + +/** Normalize one implicit property map and all descendants with explicit work frames. */ +function normalizePropertyMap( + entries: Record, + path: string, + requiredNames: ReadonlySet, + raw: boolean, +): Record { + const holder: NormalizeRoot = {} + const ancestors = new Set() + const tasks: NormalizeTask[] = [{ + kind: 'map', + entries, + path, + requiredNames, + raw, + destination: { kind: 'root', holder }, + }] + for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) { + if (task.kind === 'leave') { + ancestors.delete(task.value) + continue + } + if (task.kind === 'map') { + if (ancestors.has(task.entries)) throw new Error(`harness.defineTool ${task.path} is circular`) + assertSchemaContainerKeys(task.entries, task.path) + ancestors.add(task.entries) + const spec: Record = {} + assignNormalizedMap(task.destination, spec) + tasks.push({ kind: 'leave', value: task.entries }) + const mapEntries = Object.entries(task.entries) + for (let index = mapEntries.length - 1; index >= 0; index--) { + const entry = mapEntries[index] + /* v8 ignore next -- the loop is bounded by the captured entry count. */ + if (entry === undefined) continue + tasks.push({ + kind: 'value', + value: entry[1], + path: `${task.path}.${entry[0]}`, + forceRequired: task.requiredNames.has(entry[0]), + raw: task.raw, + parameterProperty: true, + destination: { kind: 'property', target: spec, key: entry[0] }, + }) + } + continue + } + + const { value, path } = task + if (!isPlainRecord(value)) { + throw new Error(`harness.defineTool ${path} must be a ParameterSchemaSpec property object`) + } + assertSchemaContainerKeys(value, path) + if (ancestors.has(value)) throw new Error(`harness.defineTool ${path} is circular`) + ancestors.add(value) + const requiredKey = task.parameterProperty && !task.raw ? ['required'] : [] + if (task.parameterProperty && task.raw && Object.hasOwn(value, 'required') && value.type !== 'object') { + throw new Error(`harness.defineTool ${path}.required belongs to the containing raw object schema`) + } + if (task.parameterProperty && !task.raw && Object.hasOwn(value, 'required') && value.required !== true) { + throw new Error(`harness.defineTool ${path}.required must be true when present`) + } + const prop: Record = {} + assignNormalizedValue(task.destination, prop) + tasks.push({ kind: 'leave', value }) + if (task.forceRequired || value.required === true) prop.required = true + copyAnnotations(value, prop, path) + + if (Object.hasOwn(value, 'oneOf')) { + assertSchemaKeys(value, path, ['oneOf', ...requiredKey, ...ANNOTATION_KEYS]) + if (!isDensePlainArray(value.oneOf) || value.oneOf.length < 2) { + throw new Error(`harness.defineTool ${path}.oneOf must contain at least two schemas`) + } + const oneOf: Record[] = [] + prop.oneOf = oneOf + for (let index = value.oneOf.length - 1; index >= 0; index--) { + tasks.push({ + kind: 'value', + value: value.oneOf[index], + path: `${path}.oneOf[${index}]`, + forceRequired: false, + raw: task.raw, + parameterProperty: false, + destination: { kind: 'one-of', target: oneOf, index }, + }) + } + continue + } + + if (task.raw && !Object.hasOwn(value, 'type')) { + assertSchemaKeys(value, path, ANNOTATION_KEYS) + prop.type = 'json' + continue + } + if (!SCHEMA_TYPES.has(value.type) || task.raw && value.type === 'json') { + throw new Error(`harness.defineTool ${path} must declare a valid type: ${VALID_TYPES} (got ${JSON.stringify(value.type)})`) + } + const type = value.type + prop.type = type + + switch (type) { + case 'object': { + assertSchemaKeys(value, path, ['type', 'properties', 'additionalProperties', ...requiredKey, ...(task.raw ? ['required'] : []), ...ANNOTATION_KEYS]) + if (!task.raw && (!Object.hasOwn(value, 'additionalProperties') || typeof value.additionalProperties !== 'boolean')) { + throw new Error(`harness.defineTool ${path}.additionalProperties must be explicitly true or false`) + } + if (task.raw && Object.hasOwn(value, 'additionalProperties') && typeof value.additionalProperties !== 'boolean') { + throw new Error(`harness.defineTool ${path}.additionalProperties must be a boolean`) + } + if (task.raw && Object.hasOwn(value, 'required') && value.required === undefined) { + throw new Error(`harness.defineTool ${path}.required must be an array of declared property names`) + } + prop.additionalProperties = task.raw ? value.additionalProperties ?? true : value.additionalProperties + if (Object.hasOwn(value, 'properties')) { + const properties = value.properties + if (!isPlainRecord(properties)) throw new Error(`harness.defineTool ${path}.properties must be an object of schemas`) + const nestedRequired = task.raw + ? normalizeRequiredNames(value.required, properties, `${path}.required`) + : new Set() + tasks.push({ + kind: 'map', + entries: properties, + path: `${path}.properties`, + requiredNames: nestedRequired, + raw: task.raw, + destination: { kind: 'properties', target: prop }, + }) + } else if (task.raw && value.required !== undefined) { + normalizeRequiredNames(value.required, {}, `${path}.required`) + } + break + } + case 'array': + assertSchemaKeys(value, path, ['type', 'items', ...requiredKey, ...ANNOTATION_KEYS]) + if (Object.hasOwn(value, 'items')) { + tasks.push({ + kind: 'value', + value: value.items, + path: `${path}.items`, + forceRequired: false, + raw: task.raw, + parameterProperty: false, + destination: { kind: 'item', target: prop }, + }) + } + break + case 'string': + case 'number': + case 'integer': + case 'boolean': + case 'null': + assertSchemaKeys(value, path, ['type', 'enum', 'const', ...requiredKey, ...ANNOTATION_KEYS]) + if (Object.hasOwn(value, 'enum')) { + if (!isDensePlainArray(value.enum) || value.enum.length === 0) { + throw new Error(`harness.defineTool ${path}.enum must be a non-empty array`) + } + prop.enum = cloneJson(value.enum, `${path}.enum`) + } + if (Object.hasOwn(value, 'const')) prop.const = cloneJson(value.const, `${path}.const`) + break + case 'json': + assertSchemaKeys(value, path, ['type', ...requiredKey, ...ANNOTATION_KEYS]) + break + /* v8 ignore next 2 -- SCHEMA_TYPES narrows this closed switch before dispatch. */ + default: + throw new Error(`harness.defineTool ${path} must declare a valid type: ${VALID_TYPES}`) } - prop.items = normalizeSchemaProp(value.items, `${path}.items`) } - return prop + /* v8 ignore next -- the root map task assigns before scheduling descendants. */ + return holder.value ?? {} } function markDynamicTool(tool: ToolDefinition): DynamicToolDefinition { @@ -127,60 +513,75 @@ const RETURN_PREVIEW_LIMIT = 120 * (`String(…)` for the un-stringifiable undefined case), truncated to * {@link RETURN_PREVIEW_LIMIT}. */ -function describeReturn(value: unknown): string { - // JSON.stringify is TYPED as always returning string, but it yields - // undefined for an undefined input (the routed forgot-return case) — the - // assertion widens the type back to the runtime truth. - const json = JSON.stringify(value) as string | undefined - if (json === undefined) return String(value) +function describeReturn(value: JsonValue): string { + // The caller has already crossed cloneJson, so this value is lossless JSON + // and serialization cannot produce undefined. + const json = JSON.stringify(value) return json.length > RETURN_PREVIEW_LIMIT ? `${json.slice(0, RETURN_PREVIEW_LIMIT)}…` : json } /** - * Validate a round-tripped `execute` return against the two shapes - * {@link ToolExecuteReturn} allows: an ARRAY of content blocks, or - * `{ content: blocks, meta? }`. The registry trusts the shape blindly — it - * spreads `result.content`, so an unvalidated `{ content: 'ok' }` would enter - * the session log as `['o','k']` and silently corrupt the next model request — - * so a wrong shape fails THIS call with a teaching error instead. + * Validate and host-materialize a sandbox renderer's content blocks. */ -function assertExecuteReturn(value: unknown): ToolExecuteReturn { +function assertRenderedContent(value: JsonValue): ContentBlock[] { if (Array.isArray(value) && value.every(isContentBlockShape)) { - return value as ToolExecuteReturn - } - if (isPlainRecord(value) && Array.isArray(value.content) && value.content.every(isContentBlockShape)) { - return value as ToolExecuteReturn + return value as unknown as ContentBlock[] } throw new Error( - `execute returned ${describeReturn(value)} — a tool's execute must return an ARRAY of content blocks, never a bare string:\n` - + ' ✓ return [{ type: \'text\', text: someString }]\n' - + ' ✓ return { content: [{ type: \'text\', text: someString }], meta: anyJsonValue }', + `output.render returned ${describeReturn(value)} — it must return an ARRAY of content blocks:\n` + + ' ✓ return [{ type: \'text\', text: String(value) }]', ) } /** * The `harness.defineTool` handed into the sandbox: the real DSL, with `parameters` normalized - * into a fresh host-realm SchemaSpec (JSON-Schema wrapper unwrapped, `integer` mapped, - * `required: false` dropped) and the tool's `execute` return normalized into the host realm + * into a fresh host-realm ParameterSchemaSpec (raw object wrappers unwrapped, + * required arrays mapped, and explicit DSL object openness enforced) and the tool's `execute` return normalized into the host realm * via a JSON round-trip. Non-JSON or wrong-shape output fails that call instead of poisoning * the session log. - * @param options - the standard `defineTool` options; `parameters` may be the SchemaSpec DSL or a JSON-Schema-style wrapper. + * @param options - the standard `defineTool` options; `parameters` may be the ParameterSchemaSpec DSL or a JSON-Schema-style wrapper. * @returns the marker-tagged definition `harness.registerTool` (and the guarded `ctx.tools.register`) accepts. */ -export function sandboxDefineTool(options: Parameters[0]): ToolDefinition { - const parameters = normalizeSchemaSpec((options as { parameters?: unknown }).parameters) - const tool = defineTool({ ...options, parameters } as Parameters[0]) - const execute = tool.execute.bind(tool) +export function sandboxDefineTool(options: unknown): ToolDefinition { + if (!isPlainRecord(options)) throw new Error('harness.defineTool options must be an object') + const normalized = normalizeParameterSchemaSpec(options.parameters) + if (!isPlainRecord(options.output)) { + throw new Error('harness.defineTool output must declare { schema, render, presentationMeta? }') + } + const output = options.output + if (typeof output.render !== 'function') throw new Error('harness.defineTool output.render must be a function') + if (output.presentationMeta !== undefined && typeof output.presentationMeta !== 'function') { + throw new Error('harness.defineTool output.presentationMeta must be a function when present') + } + if (typeof options.execute !== 'function') throw new Error('harness.defineTool execute must be a function') + const schema = cloneJson(output.schema, 'output.schema') + const rawExecute = options.execute as (args: unknown, exec: unknown) => Promise + 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) return markDynamicTool({ ...tool, - async execute(args, exec) { - // JSON.stringify yields NO JSON for an undefined (or function/symbol) - // return despite its string-typed signature — route that into - // assertExecuteReturn's teaching error rather than letting JSON.parse - // throw its cryptic '"undefined" is not valid JSON'. - const json = JSON.stringify(await execute(args, exec)) as string | undefined - return assertExecuteReturn(json === undefined ? undefined : JSON.parse(json) as unknown) - }, + parameters, }) } diff --git a/packages/cordis/tool-cordis/src/index.ts b/packages/cordis/tool-cordis/src/index.ts index 4fc5bd4328..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\'|\'boolean\'|\'object\'|\'array\', ' - + 'required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style ' - + '{ type: \'object\', properties, required: […] } wrapper and type \'integer\' are also accepted and normalized. A ' - + 'tool\'s `execute` MUST return an ARRAY of content blocks, e.g. `return ' - + '[{ type: \'text\', text: someString }]` — never a bare string. ' + + 'Tool parameters: each key IS a property — { type: \'string\'|\'number\'|\'integer\'|\'boolean\'|\'null\'|\'object\'|\'array\'|\'json\', ' + + 'required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and ' + + 'oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: \'object\', properties, required?: […] } wrapper is also accepted with open-by-default objects. A ' + + 'tool\'s `execute` MUST return the lossless JSON value declared by `output.schema`; ' + + '`output.render(args, value)` separately returns Native/model content blocks. ' + 'Mounts can COMPOSE: one plugin may `ctx.provide(\'name\', value)` a service and ' + 'another may declare `inject: [\'name\']` to consume it — the consumer stays pending ' + 'until the provider exists and returns to pending when the provider is unmounted. ' @@ -157,6 +162,32 @@ export function apply(ctx: Context, config: Config): void { description: 'Body of an async JS function; must `return` the plugin to mount.', }, }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + id: { type: 'string', required: true }, + pluginName: { type: 'string', required: true }, + state: { + type: 'string', + required: true, + enum: ['pending', 'loading', 'active', 'failed', 'disposed', 'unloading'], + }, + provides: { type: 'array', required: true, items: { type: 'string' } }, + waitingFor: { type: 'array', required: true, items: { type: 'string' } }, + }, + }, + render: (_args, value) => { + const note = value.waitingFor.length > 0 + ? ` — waiting for service(s): ${value.waitingFor.join(', ')} (activates when provided)` + : '' + return [{ + type: 'text', + text: `mounted ${value.id} (plugin "${value.pluginName}", state: ${value.state}${note})`, + }] + }, + }, async execute(args) { const id = `dyn-${nextId++}` const sandbox = createSandbox(id) @@ -180,10 +211,13 @@ export function apply(ctx: Context, config: Config): void { // it mounted but tell the model what it is waiting for. const missing = missingServices(ctx, fiber) const state = STATE_LABELS[fiber.state] - const note = missing.length > 0 - ? ` — waiting for service(s): ${missing.join(', ')} (activates when provided)` - : '' - return [{ type: 'text', text: `mounted ${id} (plugin "${pluginName(evaluated)}", state: ${state}${note})` }] + return { + id, + pluginName: pluginName(evaluated), + state, + provides: providedServices(ctx, fiber), + waitingFor: missing, + } }, presentCall: presentMountCall, })) @@ -202,6 +236,17 @@ export function apply(ctx: Context, config: Config): void { description: 'The dynamic mount id returned by cordis_mount (e.g. "dyn-1").', }, }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + id: { type: 'string', required: true }, + pluginName: { type: 'string', required: true }, + }, + }, + render: (_args, value) => [{ type: 'text', text: `unmounted ${value.id} (plugin "${value.pluginName}")` }], + }, async execute(args) { const mount = mounts.get(args.id) if (!mount) { @@ -209,7 +254,7 @@ export function apply(ctx: Context, config: Config): void { } await mount.fiber.dispose() mounts.delete(args.id) - return [{ type: 'text', text: `unmounted ${args.id} (plugin "${mount.pluginName}")` }] + return { id: args.id, pluginName: mount.pluginName } }, presentCall: presentUnmountCall, })) 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 e945249814..b049c6c1fa 100644 --- a/packages/cordis/tool-cordis/tests/helpers.ts +++ b/packages/cordis/tool-cordis/tests/helpers.ts @@ -47,6 +47,13 @@ export const LISTENER_CODE = ` } ` +/** Explicit content-array output declaration for dynamic-tool behavior fixtures. */ +export const CONTENT_OUTPUT_CODE = ` + output: { + schema: { type: 'array', items: { type: 'json' } }, + render(_args, value) { return value }, + },` + /** Mount code for a self-made tool: registers `reverse_text` via the sandbox's harness helpers. */ export const REVERSE_TOOL_CODE = ` return { @@ -57,8 +64,14 @@ export const REVERSE_TOOL_CODE = ` name: 'reverse_text', description: 'Reverse a string.', parameters: { text: { type: 'string', required: true } }, + output: { + schema: { type: 'string' }, + render(_args, value) { + return [{ type: 'text', text: value }] + }, + }, async execute(args) { - return [{ type: 'text', text: args.text.split('').reverse().join('') }] + return args.text.split('').reverse().join('') }, })) }, @@ -85,8 +98,14 @@ export const CONSUMER_CODE = ` name: 'greet', description: 'Greet someone via the greeter service.', parameters: { name: { type: 'string', required: true } }, + output: { + schema: { type: 'string' }, + render(_args, value) { + return [{ type: 'text', text: value }] + }, + }, async execute(args) { - return [{ type: 'text', text: ctx.greeter.greet(args.name) }] + return ctx.greeter.greet(args.name) }, })) }, @@ -99,8 +118,9 @@ export function dummyTool(name: string): ToolDefinition { name, description: 'test trigger', parameters: { type: 'object' as const, properties: {} }, - async execute(): Promise<[]> { - return [] + output: { schema: { type: 'null' }, render: () => [] }, + async execute(): Promise { + 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 2ddee8dbca..a354de0b37 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)) }) @@ -156,14 +200,18 @@ describe('cordis_mount', () => { description: 'written in the JSON-Schema dialect', parameters: { type: 'object', + title: 'Raw parameters', + default: { text: 'default' }, + examples: [{ text: 'example' }], properties: { text: { type: 'string', description: 'the text' }, count: { type: 'integer', default: 1 }, mode: { type: 'string', enum: ['fast', 'slow'] }, - extra: { type: 'string', required: false }, + extra: { type: 'string' }, }, required: ['text'], }, + ${CONTENT_OUTPUT_CODE} async execute(args) { return [{ type: 'text', text: args.text + ':' + (args.count ?? 0) }] }, })) }, @@ -173,14 +221,19 @@ describe('cordis_mount', () => { expect(result.isError).toBe(false) // The registered schema is canonical JSON Schema derived from the DSL: - // the required array survived, integer became number, extra is optional. + // the required array survived, integer stayed integer, extra is optional. const schema = ctx.tools.schemas().find(s => s.name === 'json_schema_tool')! const parameters = schema.parameters as { properties: Record required?: string[] } expect(parameters.required).toEqual(['text']) - expect(parameters.properties.count!.type).toBe('number') + expect(parameters).toMatchObject({ + title: 'Raw parameters', + default: { text: 'default' }, + examples: [{ text: 'example' }], + }) + expect(parameters.properties.count!.type).toBe('integer') expect(parameters.properties.count!.default).toBe(1) expect(parameters.properties.mode!.enum).toEqual(['fast', 'slow']) // Arg validation enforces the normalized spec: text required, extra not. @@ -202,8 +255,12 @@ describe('cordis_mount', () => { name: 'nested_json_schema_tool', description: 'nested dialect', parameters: { - cfg: { type: 'object', properties: { label: { type: 'string' } }, required: ['label'] }, + type: 'object', + properties: { + cfg: { type: 'object', properties: { label: { type: 'string' } }, required: ['label'] }, + }, }, + ${CONTENT_OUTPUT_CODE} async execute(args) { return [{ type: 'text', text: args.cfg.label }] }, })) }, @@ -217,14 +274,198 @@ describe('cordis_mount', () => { expect(text(await call(ctx, 'nested_json_schema_tool', { cfg: { label: 'hi' } }))).toBe('hi') }) + it('normalizes every unified DSL node and lossless annotation shape across the sandbox realm', async () => { + const ctx = await setup() + const result = await call(ctx, 'cordis_mount', { + code: ` + return { + name: 'unified-schema', + inject: ['tools'], + apply(ctx) { + harness.registerTool(ctx, harness.defineTool({ + name: 'unified_schema_tool', + description: 'all unified nodes', + parameters: { + any: { + type: 'json', + title: 'Any JSON', + default: { nested: [1, 'x', null] }, + examples: [{ ok: true }], + }, + choice: { + oneOf: [{ type: 'string', const: 'x' }, { type: 'null' }], + required: true, + }, + flags: { type: 'array' }, + closed: { type: 'object', additionalProperties: false }, + count: { type: 'number', enum: [1, 2], const: 1 }, + }, + ${CONTENT_OUTPUT_CODE} + async execute(args) { return [{ type: 'text', text: String(args.choice) }] }, + })) + }, + } + `, + }) + expect(result.isError).toBe(false) + const schema = ctx.tools.schemas().find(s => s.name === 'unified_schema_tool')! + expect(schema.parameters).toMatchObject({ + properties: { + any: { title: 'Any JSON', default: { nested: [1, 'x', null] }, examples: [{ ok: true }] }, + choice: { oneOf: [{ type: 'string', const: 'x' }, { type: 'null' }] }, + flags: { type: 'array' }, + closed: { type: 'object', additionalProperties: false }, + count: { type: 'number', enum: [1, 2], const: 1 }, + }, + required: ['choice'], + }) + }) + + it('normalizes and snapshots deeply nested sandbox schemas and annotations stack-safely', async () => { + const ctx = await setup() + const depth = 5_000 + const result = await call(ctx, 'cordis_mount', { + code: ` + return { + name: 'deep-unified-schema', + inject: ['tools'], + apply(ctx) { + let choice = { type: 'string' } + let example = 'leaf' + for (let index = 0; index < ${depth}; index++) { + choice = { oneOf: [choice, { type: 'null' }] } + example = [example] + } + harness.registerTool(ctx, harness.defineTool({ + name: 'deep_unified_schema_tool', + description: 'deep unified nodes', + parameters: { + choice: { ...choice, required: true }, + any: { type: 'json', default: example }, + }, + ${CONTENT_OUTPUT_CODE} + async execute() { return [] }, + })) + }, + } + `, + }) + expect(result.isError).toBe(false) + + const parameters = ctx.tools.schemas().find(s => s.name === 'deep_unified_schema_tool')!.parameters as { + properties: Record> + } + let choice = parameters.properties.choice! + let choiceDepth = 0 + while (Array.isArray(choice.oneOf)) { + choice = choice.oneOf[0] as Record + choiceDepth++ + } + let example: unknown = parameters.properties.any!.default + let exampleDepth = 0 + while (Array.isArray(example)) { + example = example[0] + exampleDepth++ + } + expect({ choiceDepth, choice, exampleDepth, example }).toEqual({ + choiceDepth: depth, + choice: { type: 'string' }, + exampleDepth: depth, + example: 'leaf', + }) + }) + + it('normalizes unconstrained and closed nested nodes from a raw JSON Schema wrapper', async () => { + const ctx = await setup() + const result = await call(ctx, 'cordis_mount', { + code: ` + return { + name: 'raw-unified-schema', + inject: ['tools'], + apply(ctx) { + harness.registerTool(ctx, harness.defineTool({ + name: 'raw_unified_schema_tool', + description: 'raw unified nodes', + parameters: { + type: 'object', + additionalProperties: true, + properties: { + any: { description: 'unconstrained' }, + cfg: { + type: 'object', + additionalProperties: false, + properties: { label: { type: 'string' } }, + required: ['label'], + }, + choice: { oneOf: [{ type: 'boolean' }, { type: 'null' }] }, + }, + }, + ${CONTENT_OUTPUT_CODE} + async execute() { return [] }, + })) + }, + } + `, + }) + expect(result.isError).toBe(false) + expect(ctx.tools.schemas().find(s => s.name === 'raw_unified_schema_tool')!.parameters).toMatchObject({ + properties: { + any: {}, + cfg: { additionalProperties: false, required: ['label'] }, + choice: { oneOf: [{ type: 'boolean' }, { type: 'null' }] }, + }, + }) + }) + it.each([ - ['parameters: 42', 'must be a SchemaSpec object'], - ['parameters: { text: 42 }', 'parameters.text must be a SchemaSpec property object'], - ['parameters: { text: { type: \'str\' } }', 'parameters.text must declare a valid type: \'string\' | \'number\' | \'boolean\' | \'object\' | \'array\' (got "str")'], - ['parameters: { text: { type: \'string\', required: \'yes\' } }', 'parameters.text.required must be a boolean when present'], - ['parameters: { text: { type: \'string\', properties: {} } }', 'parameters.text.properties is only valid for type "object"'], - ['parameters: { text: { type: \'string\', items: { type: \'string\' } } }', 'parameters.text.items is only valid for type "array"'], - ])('rejects a malformed SchemaSpec (%s) with a teaching error', async (parameters, message) => { + ['parameters: 42', 'must be a ParameterSchemaSpec object'], + ['parameters: Object.defineProperty({}, \'text\', { value: { type: \'string\' } })', 'parameters must contain only own enumerable string keys'], + ['parameters: { text: 42 }', 'parameters.text must be a ParameterSchemaSpec property object'], + ['parameters: { text: Object.defineProperty({ type: \'string\' }, \'minimum\', { value: 1 }) }', 'parameters.text must contain only own enumerable string keys'], + ['parameters: { text: { type: \'string\', [Symbol(\'hidden\')]: true } }', 'parameters.text must contain only own enumerable string keys'], + ['parameters: { text: { type: \'str\' } }', 'parameters.text must declare a valid type: \'string\' | \'number\' | \'integer\' | \'boolean\' | \'null\' | \'object\' | \'array\' | \'json\' (got "str")'], + ['parameters: { text: { type: \'string\', required: \'yes\' } }', 'parameters.text.required must be true when present'], + ['parameters: { text: { type: \'string\', properties: {} } }', 'parameters.text.properties is not supported by the unified schema DSL'], + ['parameters: { text: { type: \'string\', items: { type: \'string\' } } }', 'parameters.text.items is not supported by the unified schema DSL'], + ['parameters: { text: { type: \'object\', properties: {} } }', 'parameters.text.additionalProperties must be explicitly true or false'], + ['parameters: { text: { type: \'object\', additionalProperties: \'no\' } }', 'parameters.text.additionalProperties must be explicitly true or false'], + ['parameters: { type: \'object\' }', 'parameters.properties must be an object of schemas'], + ['parameters: { type: \'object\', properties: {}, additionalProperties: false }', 'parameters.additionalProperties must be true or omitted'], + ['parameters: { type: \'object\', properties: {}, required: \'text\' }', 'parameters.required must be an array of declared property names'], + ['parameters: { type: \'object\', properties: {}, required: undefined }', 'parameters.required must be an array of declared property names'], + ['parameters: { type: \'object\', properties: {}, required: [42] }', 'parameters.required must be an array of declared property names'], + ['parameters: (() => { const required = []; required.length = 1; return { type: \'object\', properties: {}, required } })()', 'parameters.required must be an array of declared property names'], + ['parameters: (() => { const required = []; required.length = 1; required.extra = true; return { type: \'object\', properties: {}, required } })()', 'parameters.required must be an array of declared property names'], + ['parameters: (() => { class Names extends Array { *[Symbol.iterator]() {} }; const required = new Names(); required[0] = \'text\'; required.length = 1; return { type: \'object\', properties: { text: { type: \'string\' } }, required } })()', 'parameters.required must be an array of declared property names'], + ['parameters: { type: \'object\', properties: {}, required: [\'text\'] }', 'parameters.required names undeclared property "text"'], + ['parameters: { type: \'object\', properties: { text: { type: \'string\', required: true } } }', 'parameters.text.required belongs to the containing raw object schema'], + ['parameters: { type: \'object\', properties: { text: { oneOf: \'bad\' } } }', 'parameters.text.oneOf must contain at least two schemas'], + ['parameters: { type: \'object\', properties: { text: { type: \'json\' } } }', 'parameters.text must declare a valid type'], + ['parameters: { type: \'object\', properties: { cfg: { type: \'object\', additionalProperties: \'no\' } } }', 'parameters.cfg.additionalProperties must be a boolean'], + ['parameters: { type: \'object\', properties: { cfg: { type: \'object\', properties: 42 } } }', 'parameters.cfg.properties must be an object of schemas'], + ['parameters: { type: \'object\', properties: { cfg: { type: \'object\', required: [\'label\'] } } }', 'parameters.cfg.required names undeclared property "label"'], + ['parameters: { type: \'object\', properties: { cfg: { type: \'object\', required: undefined } } }', 'parameters.cfg.required must be an array of declared property names'], + ['parameters: { value: { oneOf: \'bad\' } }', 'parameters.value.oneOf must contain at least two schemas'], + ['parameters: { value: { oneOf: new (class Branches extends Array {})({ type: \'string\' }, { type: \'null\' }) } }', 'parameters.value.oneOf must contain at least two schemas'], + ['parameters: { value: { oneOf: Object.assign([{ type: \'string\' }, { type: \'null\' }], { extra: true }) } }', 'parameters.value.oneOf must contain at least two schemas'], + ['parameters: { value: { type: \'string\', enum: \'bad\' } }', 'enum must be a non-empty array'], + ['parameters: { value: { type: \'string\', enum: new (class Values extends Array {})(\'a\', \'b\') } }', 'parameters.value.enum must be a non-empty array'], + ['parameters: { value: { type: \'json\', default: -0 } }', 'parameters.value.default must be lossless JSON data'], + ['parameters: { value: { type: \'json\', default: Infinity } }', 'parameters.value.default must be lossless JSON data'], + ['parameters: { value: { type: \'json\', default: () => 1 } }', 'parameters.value.default must be lossless JSON data'], + ['parameters: { value: { type: \'json\', default: (() => { const v = {}; v.self = v; return v })() } }', 'parameters.value.default.self must be lossless JSON data'], + ['parameters: { value: { type: \'json\', default: Array(2) } }', 'parameters.value.default must be lossless JSON data'], + ['parameters: { value: { type: \'json\', default: Object.assign([1], { extra: true }) } }', 'parameters.value.default must be lossless JSON data'], + ['parameters: { value: { type: \'json\', default: (() => { const v = Array(1); v.extra = true; return v })() } }', 'parameters.value.default must be lossless JSON data'], + ['parameters: { value: { type: \'json\', default: Object.defineProperty({}, \'hidden\', { value: true }) } }', 'parameters.value.default must be lossless JSON data'], + ['parameters: { value: { type: \'json\', default: { [Symbol(\'hidden\')]: true } } }', 'parameters.value.default must be lossless JSON data'], + ['parameters: { value: { type: \'json\', default: new (class DefaultValue { constructor() { this.ok = true } })() } }', 'parameters.value.default must be lossless JSON data'], + ['parameters: { value: { type: \'json\', default: new (class DefaultList extends Array {})() } }', 'parameters.value.default must be lossless JSON data'], + ['parameters: { value: { type: \'json\', default: new Date(0) } }', 'parameters.value.default must be lossless JSON data'], + ['parameters: (() => { const p = Object.create(null); const C = function C() {}; Object.defineProperty(C, \'name\', { value: \'Object\' }); C.prototype = p; Object.defineProperty(p, \'constructor\', { value: C }); return Object.create(p) })()', 'must be a ParameterSchemaSpec object'], + ['parameters: (() => { const p = Object.create(null); const C = function C() {}; Object.defineProperty(C, \'name\', { value: \'Object\' }); C.prototype = p; const r = Proxy.revocable(C, {}); Object.defineProperty(p, \'constructor\', { value: r.proxy }); r.revoke(); return Object.create(p) })()', 'must be a ParameterSchemaSpec object'], + ['parameters: Object.create(Object.create(null))', 'must be a ParameterSchemaSpec object'], + ])('rejects a malformed ParameterSchemaSpec (%s) with a teaching error', async (parameters, message) => { const ctx = await setup() const result = await call(ctx, 'cordis_mount', { code: ` @@ -236,6 +477,7 @@ describe('cordis_mount', () => { name: 'bad_schema_tool', description: 'bad', ${parameters}, + ${CONTENT_OUTPUT_CODE} async execute() { return [] }, })) }, @@ -246,7 +488,83 @@ describe('cordis_mount', () => { expect(text(result)).toContain(message) }) - it('accepts a nested object/array SchemaSpec (the DSL recursion)', async () => { + it.each([ + [ + ` + const parameters = {} + const item = { type: 'array' } + item.items = item + parameters.item = item + `, + 'parameters.item.items is circular', + ], + [ + ` + const parameters = {} + const item = { type: 'object', additionalProperties: true, properties: parameters } + parameters.item = item + `, + 'parameters.item.properties is circular', + ], + ])('rejects circular sandbox schemas without exhausting the call stack', async (declaration, message) => { + const ctx = await setup() + const result = await call(ctx, 'cordis_mount', { + code: ` + return { + name: 'circular-schema', + inject: ['tools'], + apply(ctx) { + ${declaration} + harness.registerTool(ctx, harness.defineTool({ + name: 'circular_schema_tool', + description: 'circular', + parameters, + async execute() { return [] }, + })) + }, + } + `, + }) + expect(result.isError).toBe(true) + expect(text(result)).toContain(message) + }) + + it('preserves literal __proto__ keys in sandbox schemas and annotations', async () => { + const ctx = await setup() + const result = await call(ctx, 'cordis_mount', { + code: ` + return { + name: 'proto-schema', + inject: ['tools'], + apply(ctx) { + harness.registerTool(ctx, harness.defineTool({ + name: 'proto_schema_tool', + description: 'literal JSON keys', + parameters: { + ['__proto__']: { type: 'string', required: true }, + value: { type: 'json', default: { ['__proto__']: { safe: true } } }, + }, + ${CONTENT_OUTPUT_CODE} + async execute() { return [] }, + })) + }, + } + `, + }) + + expect(result.isError).toBe(false) + const parameters = ctx.tools.schemas().find(schema => schema.name === 'proto_schema_tool')!.parameters as { + properties: Record + required?: string[] + } + expect(Object.hasOwn(parameters.properties, '__proto__')).toBe(true) + expect(parameters.required).toContain('__proto__') + const defaultValue = parameters.properties.value!.default as Record + expect(Object.hasOwn(defaultValue, '__proto__')).toBe(true) + expect(defaultValue.__proto__).toEqual({ safe: true }) + }) + + it('accepts a nested object/array ParameterSchemaSpec (the DSL recursion)', async () => { const ctx = await setup() const result = await call(ctx, 'cordis_mount', { code: ` @@ -258,9 +576,10 @@ describe('cordis_mount', () => { name: 'nested_schema_tool', description: 'nested', parameters: { - item: { type: 'object', required: true, properties: { label: { type: 'string', required: true } } }, + item: { type: 'object', additionalProperties: true, required: true, properties: { label: { type: 'string', required: true } } }, tags: { type: 'array', items: { type: 'string' } }, }, + ${CONTENT_OUTPUT_CODE} async execute(args) { return [{ type: 'text', text: args.item.label }] }, })) }, @@ -284,6 +603,7 @@ describe('cordis_mount', () => { name: 'raw_dynamic_tool', description: 'raw', parameters: { type: 'object', properties: {} }, + ${CONTENT_OUTPUT_CODE} async execute() { return [] }, }) }, @@ -337,6 +657,14 @@ describe('cordis_mount', () => { code: 'return { name: \'waiter\', inject: [\'no-such-service\'], apply(ctx) {} }', }) expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected pending cordis_mount success') + expect(result.value).toEqual({ + id: 'dyn-1', + pluginName: 'waiter', + state: 'pending', + provides: [], + waitingFor: ['no-such-service'], + }) expect(text(result)).toContain('state: pending') expect(text(result)).toContain('waiting for service(s): no-such-service') // Unmounting a pending mount works like any other. @@ -397,6 +725,7 @@ describe('cordis_mount', () => { name: 'cordis_mount', description: 'dup', parameters: {}, + ${CONTENT_OUTPUT_CODE} async execute() { return [] }, })) }, @@ -543,6 +872,7 @@ describe('cordis_mount', () => { name: 'probe_instanceof', description: 'report instanceof checks across realms', parameters: { items: { type: 'array', required: true, items: { type: 'string' } } }, + ${CONTENT_OUTPUT_CODE} async execute(args) { const checks = { hostArray: args.items instanceof Array, 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 64c6c69cf6..b6e83d7b23 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 aborted before dispatch' }], isError: true, - error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + error: { + message: 'tool call aborted before dispatch', + info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + }, }, callSeq) } @@ -245,7 +248,7 @@ function appendToolResult( callId: block.id, content: result.content, isError: result.isError, - ...result.error ? { error: result.error } : {}, + ...result.error?.info ? { error: result.error.info } : {}, // The tool's private presentation payload (e.g. a result-time diff), // persisted so a UI bridge reproduces the card on replay. ...result.meta !== undefined ? { meta: result.meta } : {}, diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index 70c02bbeed..c6c767d422 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' const testToolSignal = new AbortController().signal @@ -189,7 +189,7 @@ describe('AgentLoop initiator scope', () => { ctx.on('agent/turn-stop', (subject, _turn, signal) => { if (subject === agent) capture(signal) }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'observe', description: 'observe explicit turn state', parameters: {}, @@ -232,7 +232,7 @@ describe('AgentLoop initiator scope', () => { let parentWhileChildDriverActive: Agent | undefined let child: Agent | undefined - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'spawn-child', description: 'create one child agent', parameters: {}, @@ -244,7 +244,7 @@ describe('AgentLoop initiator scope', () => { setup: (agentCtx) => { parentDuringSetup = ctx.agents.requireInitiator() explicitChild = agentCtx.agent - agentCtx.tools.register(defineTool({ + agentCtx.tools.register(defineContentToolFixture({ name: 'observe-child', description: 'observe child execution identity', parameters: {}, @@ -292,7 +292,7 @@ describe('AgentLoop initiator scope', () => { let directAmbient: Agent | undefined let captured: Agent | undefined - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'agentless-probe', description: 'observe an agentless call', parameters: {}, @@ -302,7 +302,7 @@ describe('AgentLoop initiator scope', () => { return [{ type: 'text', text: 'ok' }] }, })) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'capability-request', description: 'call the test capability transport', parameters: { path: { type: 'string' } }, diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 162d3b552a..cbf2c70564 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -11,7 +11,7 @@ import { Context } from 'cordis' import LlmService, { type Message } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -365,7 +365,7 @@ describe('Agent.cancel()', () => { ]) const ctx = await harness(adapter) let executions = 0 - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'danger', description: 'must not run after cancellation', parameters: {}, @@ -952,7 +952,7 @@ describe('Agent.cancel()', () => { }) break case 'tool': - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'blocked', description: 'wait for cancellation', parameters: {}, diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 3a9ca87d68..07cf87f57d 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, TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture, TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent, type ContinuationDecision, type HookContext } from '@deepseek-ai/dsh-agent' import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop' import { prepareReactLoopAgent } from '../src/agent.ts' @@ -60,7 +60,7 @@ describe('session log records what agent/step-result actually produced', () => { const adapter = new MockAdapter([original, textResponse('done')]) const ctx = await harness(adapter) const executed: string[] = [] - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'injected-tool', description: '', parameters: {}, @@ -229,7 +229,7 @@ describe('abort during tool execution ends the turn', () => { const ctx = await harness(adapter) const executed: string[] = [] const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'aborter', description: '', parameters: {}, @@ -250,7 +250,7 @@ describe('abort during tool execution ends the turn', () => { source: { kind: 'plugin', plugin: 'abort-test' }, }], })) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'second', description: '', parameters: {}, @@ -332,7 +332,7 @@ describe('abort during tool execution ends the turn', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'aborter', {})]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a-abort-injection'), { provider: 'mock', model: 'mock' }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'aborter', description: '', parameters: {}, @@ -378,7 +378,7 @@ describe('abort during tool execution ends the turn', () => { ] satisfies StreamChunk[]]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a-later-abort-context'), { provider: 'mock', model: 'mock' }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'first', description: '', parameters: {}, @@ -386,7 +386,7 @@ describe('abort during tool execution ends the turn', () => { return [{ type: 'text', text: 'first done' }] }, })) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'aborter', description: '', parameters: {}, @@ -427,7 +427,7 @@ describe('abort during tool execution ends the turn', () => { const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('a-dispose-injection'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'waiter', description: '', parameters: {}, @@ -479,7 +479,7 @@ describe('abort during tool execution ends the turn', () => { ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a-historical-tool-pair'), { provider: 'mock', model: 'mock' }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'aborter', description: '', parameters: {}, @@ -488,7 +488,7 @@ describe('abort during tool execution ends the turn', () => { return [{ type: 'text', text: 'done' }] }, })) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'second', description: '', parameters: {}, @@ -767,7 +767,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'noop', description: '', parameters: {}, @@ -850,7 +850,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { const agent = ctx.agentLoop.create(SessionId('owned-steer'), { provider: 'mock', model: 'mock' }) const entered = Promise.withResolvers() const release = Promise.withResolvers() - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'gate', description: '', parameters: {}, @@ -1497,7 +1497,7 @@ describe('tool result call identity', () => { textResponse('done'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'echo', parameters: { x: { type: 'number' } }, diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 7d4e79238e..38ea3d103e 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: {}, diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 42a2808170..9e1663dcfe 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' @@ -399,7 +399,7 @@ describe('agent/session-prefix', () => { textResponse('again'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) @@ -521,7 +521,7 @@ describe('agent/session-prefix', () => { textResponse('done'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) @@ -574,7 +574,7 @@ describe('agent/turn-continuation (ContinuationDecision)', () => { it('a stop decision ends the turn even when the step had tool calls', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'hi' })]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) @@ -604,7 +604,7 @@ describe('tool additionalContexts buffering across a step', () => { ] const adapter = new MockAdapter([twoCalls, textResponse('done')]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) @@ -646,7 +646,7 @@ describe('tool additionalContexts buffering across a step', () => { it('appends multiple contexts deferred by one composite tool after its outer result', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'composite', {}), textResponse('done')]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'composite', description: 'composite', parameters: {}, async execute(_args, exec) { exec.deferContext({ content: [{ type: 'text', text: 'nested-a' }], source: { kind: 'plugin', plugin: 'a' }, meta: { order: 1 } }) @@ -677,7 +677,7 @@ describe('tools/pre-execute gate (native-plugin permission pattern, end-to-end t const adapter = new MockAdapter([toolCallResponse('c1', 'danger', {}), textResponse('ok')]) const ctx = await harness(adapter) let ran = false - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'danger', description: 'danger', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] }, })) @@ -739,7 +739,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'hi' }), textResponse('done')]) const ctx = await harness(adapter) await ctx.plugin(NativeGuard) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 5eaaa2ea5a..f8f1901a20 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) => { + ])('rejects non-JSON presentation metadata (%s) before the durable result commit', async (_kind, meta) => { const adapter = new MockAdapter([ toolCallResponse('bad-meta-call', 'bad-meta', {}, 'calling'), textResponse('recovered'), @@ -258,7 +263,12 @@ describe('agent loop', () => { name: 'bad-meta', description: 'returns invalid durable metadata', parameters: {}, - execute: () => Promise.resolve({ content: [{ type: 'text' as const, text: 'apparent success' }], meta }), + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + presentationMeta: () => meta as unknown as JsonValue, + }, + execute: () => Promise.resolve('apparent success'), })) const agent = ctx.agentLoop.create(SessionId('bad-meta-agent'), { provider: 'mock', model: 'mock' }) @@ -271,15 +281,16 @@ describe('agent loop', () => { expect(result.data.callId).toBe('bad-meta-call') expect(result.data.isError).toBe(true) expect(result.data.meta).toBeUndefined() + expect(result.data.error).toEqual({ name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' }) expect(result.data.content).toEqual([{ type: 'text', - text: 'Error: tool result must be losslessly JSON-serializable', + text: 'Error: tool "bad-meta" returned invalid output: output.presentationMeta returned non-lossless JSON', }]) } // The normalized failure was durably logged and fed back to the model; the // turn continued normally instead of failing after an apparent success. expect(adapter.requests).toHaveLength(2) - expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('losslessly JSON-serializable') + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('output.presentationMeta returned non-lossless JSON') }) it('omits the system field when a system-prompt/assemble veto empties the assembly', async () => { @@ -326,7 +337,7 @@ describe('agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'slow', description: '', parameters: {}, @@ -432,7 +443,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let visibleDuringTool = false const meta = { kind: 'deferred-test', version: 1 } - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'noticer', description: 'injects a notice', parameters: {}, @@ -494,7 +505,7 @@ describe('agent loop', () => { ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('invalid-context'), { provider: 'mock', model: 'mock' }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'invalid-injector', description: 'attempts an invalid context injection', parameters: {}, @@ -541,7 +552,7 @@ describe('agent loop', () => { it('agent/turn-continuation can veto continuation despite tool calls (budget-guard pattern)', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'x' })]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: '', parameters: { text: { type: 'string' } }, @@ -589,7 +600,7 @@ describe('agent loop', () => { textResponse('done'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'echo', parameters: {}, async execute() { return [{ type: 'text', text: 'echoed' }] }, })) @@ -781,7 +792,7 @@ describe('agent loop', () => { ]]) const ctx = await harness(adapter) let executions = 0 - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: '', parameters: { text: { type: 'string' } }, @@ -821,7 +832,7 @@ describe('agent loop', () => { { type: 'finish', reason: { kind: 'max-tokens' } }, ]]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: '', parameters: { text: { type: 'string' } }, @@ -908,7 +919,7 @@ describe('agent loop', () => { textResponse('continued after tool call'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: '', parameters: { text: { type: 'string' } }, @@ -1240,7 +1251,7 @@ describe('agent loop', () => { textResponse('done'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: '', parameters: { text: { type: 'string' } }, 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 aad90a7dde..63c59618e3 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 1e6bc14548..3bbfeba47f 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 35985c9185..93378142b3 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, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -61,7 +61,7 @@ function multiCall(calls: { id: string; name: string; args: object }[]): StreamC function gatedTool(name: string, parallel: boolean) { const gates = new Map void>() const started: string[] = [] - const tool = defineTool({ + const tool = defineContentToolFixture({ name, description: `gated ${name}`, parameters: { id: { type: 'string', required: true } }, @@ -123,12 +123,12 @@ describe('tool-call scheduler: grouping and barriers', () => { textResponse('done'), ]) const ctx = await harness(adapter) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'r', description: 'read', parameters: { id: { type: 'string', required: true } }, isConcurrencySafe: () => true, async execute(args) { order.push(`r-start-${args.id}`); order.push(`r-end-${args.id}`); return [{ type: 'text', text: 'r' }] }, })) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'w', description: 'write', parameters: { id: { type: 'string', required: true } }, async execute(args) { order.push(`w-${args.id}`); return [{ type: 'text', text: 'w' }] }, })) @@ -150,14 +150,14 @@ describe('tool-call scheduler: grouping and barriers', () => { ]) const ctx = await harness(adapter) const replacement = gatedExclusiveTool('x') - const disposeSafe = ctx.tools.register(defineTool({ + const disposeSafe = ctx.tools.register(defineContentToolFixture({ name: 'x', description: 'initially safe', parameters: { id: { type: 'string', required: true } }, isConcurrencySafe: () => true, async execute(args) { return [{ type: 'text', text: `old-${args.id}` }] }, })) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'replace', description: 'replace x', parameters: { id: { type: 'string', required: true } }, @@ -539,10 +539,14 @@ describe('tool-call scheduler: abort handling', () => { .toEqual([CallId('c1'), CallId('c2'), CallId('c3'), CallId('c4')]) expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId)) .toEqual([CallId('c1'), CallId('c2'), CallId('c3'), CallId('c4')]) - expect(events(agent).filter(e => e.type === 'tool/result').slice(-2).map(e => e.data)) + expect(events(agent).filter(e => e.type === 'tool/result').slice(-2).map(e => ({ + callId: e.data.callId, + isError: e.data.isError, + errorInfo: e.data.error, + }))) .toEqual([ - expect.objectContaining({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }), - expect.objectContaining({ callId: CallId('c4'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }), + { callId: CallId('c3'), isError: true, errorInfo: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }, + { callId: CallId('c4'), isError: true, errorInfo: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }, ]) const settled = events(agent).filter(e => e.type === 'tool/result' || e.type === 'context/message') expect(settled.map(e => e.type)) @@ -565,7 +569,7 @@ describe('tool-call scheduler: abort handling', () => { const gated = gatedParallelTool('p') const exclusive: string[] = [] ctx.tools.register(gated.tool) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'x', description: 'exclusive', parameters: { id: { type: 'string', required: true } }, 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 c0516415c0..eb6e6b4da5 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' @@ -39,7 +39,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 c574174a20..0bd0265f78 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -46,7 +46,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. ### Lossless JSON utilities -Durable values need one accepted representation, not a check followed by a second read. `isJsonValue(value)` is the boolean predicate; `snapshotJsonValue(value)` recursively validates and copies a plain value in one pass, returning `undefined` for invalid input and propagating a throwing getter. The snapshot helper accepts finite JSON numbers except `-0` (JSON rewrites it to `0`), dense ordinary arrays, and plain or null-prototype objects; it rejects cycles, unsupported scalars, and exotic prototypes before normalization. +Durable values need one accepted representation, not a check followed by a second read. `isJsonValue(value)` is the boolean predicate; `snapshotJsonValue(value)` iteratively validates and copies a plain value in one pass, returning `undefined` for invalid input and propagating a throwing getter. The snapshot helper accepts finite JSON numbers except `-0` (JSON rewrites it to `0`), dense ordinary arrays, and plain or null-prototype objects; it rejects cycles, unsupported scalars, and exotic prototypes before normalization without imposing a call-stack depth limit. ### Chunk-row storage codec (`chunk-rows.ts`) @@ -66,6 +66,8 @@ Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/ `context/message` renders its `content` verbatim as a user-role message, and may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`. A `user/message` or `steering/message` with prompt-prefix context keeps the exact combined model bytes in `content` and stores a model-hidden `envelope` containing the direct `displayContent` and prefix context source/metadata descriptors. `displayPromptContent()` selects the human-facing prompt without changing derived history. +`tool/result` persists the model-facing content, optional internal failure identity, and optional presentation metadata. A tool's successful canonical `value` and human-readable canonical failure message remain execution-local; rendered error content is the replay-authoritative message. This preserves the existing event shape and does not change `SESSION_FORMAT_VERSION`. + ### Session event vocabulary (`types.ts`) The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message. Provider/model/replay provenance rides on `assistant/message`; an operational error's step is on `turn/end.reason` for `kind: 'error'`, with structured provider facts for a final model-request failure. diff --git a/packages/core/session/src/json.ts b/packages/core/session/src/json.ts index 35874a49e5..43e9f0625f 100644 --- a/packages/core/session/src/json.ts +++ b/packages/core/session/src/json.ts @@ -3,82 +3,179 @@ /** * A value that round-trips losslessly through JSON: `null`, a boolean, a finite * number other than negative zero, a string, an array of such values, or a - * plain object whose values are such values. TypeScript cannot distinguish - * `-0` from `number`, so {@link isJsonValue} and {@link snapshotJsonValue} - * enforce that last numeric detail at runtime. Use this type for a payload that - * must survive session-log persistence and replay byte-identically — e.g. a - * tool's private presentation `meta`. + * plain object whose values are such values. Arrays may carry only their dense + * indexed elements; extra own properties would be discarded by JSON. TypeScript + * cannot distinguish `-0` from `number`, so {@link isJsonValue} and + * {@link snapshotJsonValue} enforce these details at runtime. Use this type for + * a payload that must survive session-log persistence and replay byte-identically + * — e.g. a tool's private presentation `meta`. */ export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } +/** Whether a realm-owned intrinsic prototype is backed by its native constructor. */ +function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): boolean { + const descriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor') + const constructor: unknown = descriptor?.value + if (typeof constructor !== 'function') return false + try { + return constructor.name === name + && constructor.prototype === prototype + && Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }` + } catch { + return false + } +} + +/** Whether a candidate is one realm's intrinsic `Object.prototype`. */ +function isIntrinsicObjectPrototype(value: object): boolean { + return Object.getPrototypeOf(value) === null && hasIntrinsicConstructor(value, 'Object') +} + +/** Whether an array uses one realm's intrinsic `Array.prototype`, not a subclass or forged prototype. */ +function hasPlainArrayPrototype(value: unknown[]): boolean { + const prototype: unknown = Object.getPrototypeOf(value) + if (!Array.isArray(prototype) || !hasIntrinsicConstructor(prototype, 'Array')) return false + const objectPrototype: unknown = Object.getPrototypeOf(prototype) + return typeof objectPrototype === 'object' + && objectPrototype !== null + && isIntrinsicObjectPrototype(objectPrototype) +} + +/** Whether an object is a plain or null-prototype record from any JavaScript realm. */ +function hasPlainObjectPrototype(value: object): boolean { + const prototype: unknown = Object.getPrototypeOf(value) + return prototype === null + || typeof prototype === 'object' && isIntrinsicObjectPrototype(prototype) +} + +/** Return every JSON-visible object key, or reject own data JSON would discard. */ +function enumerableStringKeys(value: object): string[] | undefined { + const keys = Reflect.ownKeys(value) + if (keys.some(key => typeof key !== 'string' || !Object.prototype.propertyIsEnumerable.call(value, key))) return undefined + return keys as string[] +} + +type SnapshotDestination = + | { kind: 'root' } + | { kind: 'array'; target: JsonValue[]; index: number } + | { kind: 'object'; target: { [key: string]: JsonValue }; key: string } + +type JsonWalkTask = + | { kind: 'visit'; value: unknown; destination?: SnapshotDestination } + | { kind: 'array-item'; source: unknown[]; index: number; target?: JsonValue[] } + | { kind: 'object-property'; source: Record; key: string; target?: { [key: string]: JsonValue } } + | { kind: 'leave'; source: object } + +/** Validate lossless JSON iteratively, optionally materializing a detached snapshot. */ +function walkJsonValue(value: unknown, detach: boolean): JsonValue | true | undefined { + const ancestors = new Set() + let root: JsonValue | undefined + const assign = (destination: SnapshotDestination | undefined, item: JsonValue): void => { + if (destination === undefined) return + if (destination.kind === 'root') { + root = item + } else if (destination.kind === 'array') { + destination.target[destination.index] = item + } else { + Object.defineProperty(destination.target, destination.key, { + value: item, + enumerable: true, + configurable: true, + writable: true, + }) + } + } + + const tasks: JsonWalkTask[] = [{ + kind: 'visit', + value, + ...(detach ? { destination: { kind: 'root' } as const } : {}), + }] + for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) { + if (task.kind === 'leave') { + ancestors.delete(task.source) + continue + } + if (task.kind === 'array-item') { + if (!Object.prototype.hasOwnProperty.call(task.source, task.index)) return undefined + tasks.push({ + kind: 'visit', + value: task.source[task.index], + ...(task.target === undefined ? {} : { destination: { kind: 'array', target: task.target, index: task.index } as const }), + }) + continue + } + if (task.kind === 'object-property') { + tasks.push({ + kind: 'visit', + value: task.source[task.key], + ...(task.target === undefined ? {} : { destination: { kind: 'object', target: task.target, key: task.key } as const }), + }) + continue + } + + const current = task.value + if (current === null) { + assign(task.destination, null) + continue + } + if (typeof current === 'boolean' || typeof current === 'string') { + assign(task.destination, current) + continue + } + if (typeof current === 'number') { + if (!Number.isFinite(current) || Object.is(current, -0)) return undefined + assign(task.destination, current) + continue + } + if (typeof current !== 'object') return undefined + if (ancestors.has(current)) return undefined + + if (Array.isArray(current)) { + if (!hasPlainArrayPrototype(current)) return undefined + const length = current.length + if (Reflect.ownKeys(current).length !== length + 1) return undefined + const target = detach ? [] as JsonValue[] : undefined + if (target !== undefined) assign(task.destination, target) + ancestors.add(current) + tasks.push({ kind: 'leave', source: current }) + for (let index = length - 1; index >= 0; index--) { + tasks.push({ kind: 'array-item', source: current, index, ...(target === undefined ? {} : { target }) }) + } + continue + } + + if (!hasPlainObjectPrototype(current)) return undefined + const keys = enumerableStringKeys(current) + if (keys === undefined) return undefined + const target = detach ? {} as { [key: string]: JsonValue } : undefined + if (target !== undefined) assign(task.destination, target) + ancestors.add(current) + tasks.push({ kind: 'leave', source: current }) + for (let index = keys.length - 1; index >= 0; index--) { + const key = keys[index] + /* v8 ignore next -- the loop is bounded by the captured key count. */ + if (key === undefined) return undefined + tasks.push({ kind: 'object-property', source: current as Record, key, ...(target === undefined ? {} : { target }) }) + } + } + return detach ? root : true +} + /** * Validate and detach lossless JSON in one read per property, so a stateful - * getter cannot change between validation and copying. Accepts ordinary arrays, - * plain or null-prototype objects, and JSON scalars; rejects sparse, cyclic, - * exotic, negative-zero, and non-finite values. Getter throws propagate. + * getter cannot change between validation and copying. Traversal is iterative, + * so valid nesting is bounded by available memory rather than the JavaScript + * call stack. Accepts ordinary arrays, plain or null-prototype objects, and JSON + * scalars; rejects sparse, cyclic, exotic, negative-zero, and non-finite values. + * Getter throws propagate. * * @param value - the candidate value to validate and detach. * @returns the detached snapshot, or `undefined` when the value is not * losslessly JSON-serializable. */ export function snapshotJsonValue(value: T): T | undefined { - const ancestors = new Set() - - const visit = (current: unknown): JsonValue | undefined => { - if (current === null) return null - switch (typeof current) { - case 'boolean': - case 'string': - return current - case 'number': - return Number.isFinite(current) && !Object.is(current, -0) ? current : undefined - case 'bigint': - case 'function': - case 'symbol': - case 'undefined': - return undefined - case 'object': - break - } - - if (ancestors.has(current)) return undefined - ancestors.add(current) - try { - if (Array.isArray(current)) { - if (Object.getPrototypeOf(current) !== Array.prototype) return undefined - const length = current.length - const snapshot: JsonValue[] = [] - for (let index = 0; index < length; index++) { - if (!Object.prototype.hasOwnProperty.call(current, index)) return undefined - const item = visit(current[index]) - if (item === undefined) return undefined - snapshot.push(item) - } - return snapshot - } - - const prototype = Object.getPrototypeOf(current) as unknown - if (prototype !== Object.prototype && prototype !== null) return undefined - const snapshot: { [key: string]: JsonValue } = {} - for (const key of Object.keys(current)) { - const item = visit((current as Record)[key]) - if (item === undefined) return undefined - // Define the key as data so a JSON field literally named "__proto__" - // cannot mutate the snapshot's prototype through ordinary assignment. - Object.defineProperty(snapshot, key, { - value: item, - enumerable: true, - configurable: true, - writable: true, - }) - } - return snapshot - } finally { - ancestors.delete(current) - } - } - - return visit(value) as T | undefined + return walkJsonValue(value, true) as T | undefined } /** @@ -86,45 +183,8 @@ export function snapshotJsonValue(value: T): T | undefined { * detaching it. Only own enumerable string properties participate; `toJSON` * is ignored and getters run, so persistence boundaries use the snapshotter. * @param value - the candidate event data to test. - * @param seen - current recursion path; callers omit it. * @returns whether `value` survives JSON round-trip losslessly. */ -export function isJsonValue(value: unknown, seen: Set = new Set()): boolean { - if (value === null) return true - switch (typeof value) { - case 'boolean': - case 'string': - return true - case 'number': - return Number.isFinite(value) && !Object.is(value, -0) - case 'bigint': - case 'function': - case 'symbol': - case 'undefined': - return false - case 'object': - break // handled below - } - // object - if (seen.has(value)) return false // circular - seen.add(value) - try { - if (Array.isArray(value)) { - if (Object.getPrototypeOf(value) !== Array.prototype) return false - // Reject sparse arrays: a hole is skipped by `every`/`forEach` but - // JSON.stringify writes it as `null`, so `[1, , 3]` would round-trip - // lossily. Require every index 0..length-1 to be an OWN property. - for (let i = 0; i < value.length; i++) { - if (!Object.prototype.hasOwnProperty.call(value, i)) return false - if (!isJsonValue(value[i], seen)) return false - } - return true - } - // Plain object only (reject Map/Set/Date/class instances). - const proto = Object.getPrototypeOf(value) as unknown - if (proto !== Object.prototype && proto !== null) return false - return Object.values(value).every(v => isJsonValue(v, seen)) - } finally { - seen.delete(value) - } +export function isJsonValue(value: unknown): boolean { + return walkJsonValue(value, false) === true } diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 37c174ea12..8b3a1e8cb6 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -275,15 +275,25 @@ export interface SessionEventMap { */ 'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string } /** - * A completed tool call's model-facing result, plus an optional tool-private - * `meta` presentation payload. `meta` is opaque to the core (`unknown` — the - * producing tool owns its shape and reads it back in `presentResult`) but MUST - * be JSON-serializable: `Session.append` runtime-validates all event data with - * `isJsonValue`, so a non-serializable `meta` is rejected at the source, and the - * durable log reproduces the identical card on replay. Absent unless the tool - * attaches one (e.g. `dsh-tool-fs` carries its result-time contextual diff here). + * A completed tool call's model-facing result, optional internal failure + * identity, and optional tool-private `meta` presentation payload. `meta` is + * opaque to the core (the producing tool owns its shape and reads it back in + * `presentResult`) but MUST be JSON-serializable: `Session.append` + * runtime-validates all event data with `isJsonValue`, so a non-serializable + * `meta` is rejected at the source, and the durable log reproduces the + * identical card on replay. Absent + * unless the tool attaches one (e.g. `dsh-tool-fs` carries its result-time + * contextual diff here). */ - 'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown } + 'tool/result': { + turn: number + step: number + callId: CallId + content: ContentBlock[] + isError: boolean + error?: { name: string; code: string } + meta?: JsonValue + } /** Steering content injected between steps of a running turn. */ 'steering/message': PromptMessageData & { turn: number } /** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */ diff --git a/packages/core/session/tests/json.spec.ts b/packages/core/session/tests/json.spec.ts index 4fb06fd744..d81266440e 100644 --- a/packages/core/session/tests/json.spec.ts +++ b/packages/core/session/tests/json.spec.ts @@ -1,5 +1,17 @@ +import { runInNewContext } from 'node:vm' import { describe, expect, it } from 'vitest' -import { isJsonValue, snapshotJsonValue } from '@deepseek-ai/dsh-session' +import { isJsonValue, snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-session' + +function objectWithForgedIntrinsicPrototype(revoked = false): Record { + const prototype = Object.create(null) as Record + const ForgedObject = function ForgedObject(): void {} + Object.defineProperty(ForgedObject, 'name', { value: 'Object' }) + ForgedObject.prototype = prototype + const constructor = revoked ? Proxy.revocable(ForgedObject, {}) : undefined + if (constructor !== undefined) constructor.revoke() + Object.defineProperty(prototype, 'constructor', { value: constructor?.proxy ?? ForgedObject }) + return Object.assign(Object.create(prototype) as Record, { value: 1 }) +} describe('snapshotJsonValue', () => { it('copies the complete JSON scalar vocabulary and rejects unsupported scalars', () => { @@ -36,6 +48,22 @@ describe('snapshotJsonValue', () => { expect(Object.getPrototypeOf(snapshot.list[0])).toBe(Object.prototype) }) + it('accepts intrinsic plain containers from another JavaScript realm', () => { + const foreign = runInNewContext('({ object: { nested: [1] }, array: [2, { ok: true }] })') as { + object: { nested: number[] } + array: JsonValue[] + } + + expect(isJsonValue(foreign.object)).toBe(true) + expect(isJsonValue(foreign.array)).toBe(true) + const objectSnapshot = snapshotJsonValue(foreign.object)! + const arraySnapshot = snapshotJsonValue(foreign.array)! + expect(objectSnapshot).toEqual({ nested: [1] }) + expect(arraySnapshot).toEqual([2, { ok: true }]) + expect(Object.getPrototypeOf(objectSnapshot)).toBe(Object.prototype) + expect(Object.getPrototypeOf(arraySnapshot)).toBe(Array.prototype) + }) + it('reads each object value and array slot once while materializing', () => { class Exotic { readonly accepted = false @@ -63,19 +91,64 @@ describe('snapshotJsonValue', () => { expect(arrayReads).toBe(1) }) - it('rejects exotic containers, sparse arrays, cycles, and invalid children', () => { + it('accepts deeply nested valid JSON without using the JavaScript call stack', () => { + let value: JsonValue = 'leaf' + for (let depth = 0; depth < 5_000; depth++) value = [value] + + expect(isJsonValue(value)).toBe(true) + let cursor: JsonValue | undefined = snapshotJsonValue(value) + for (let depth = 0; depth < 5_000; depth++) { + expect(Array.isArray(cursor)).toBe(true) + cursor = Array.isArray(cursor) ? cursor[0] : undefined + } + expect(cursor).toBe('leaf') + }) + + it('rejects exotic containers, sparse or decorated arrays, cycles, and invalid children', () => { class ExoticObject { readonly value = 1 } class ExoticArray extends Array {} const sparse = new Array(1) + const compensatedSparse = new Array(1) + Object.defineProperty(compensatedSparse, 'extra', { value: true }) + const decorated = [1] + Object.defineProperty(decorated, 'extra', { value: true }) + const symbolDecorated = [1] + Object.defineProperty(symbolDecorated, Symbol('extra'), { value: true }) + const hiddenObject = Object.defineProperty({}, 'hidden', { value: true }) + const symbolObject = { [Symbol('extra')]: true } + const customPrototype = Object.create(null) as Record + const customPrototypeObject = Object.assign(Object.create(customPrototype) as Record, { value: 1 }) + const forgedIntrinsicObject = objectWithForgedIntrinsicPrototype() + const revokedIntrinsicObject = objectWithForgedIntrinsicPrototype(true) + const forgedPrototype: unknown[] = [] + Object.setPrototypeOf(forgedPrototype, null) + const forgedArray = [1] + Object.setPrototypeOf(forgedArray, forgedPrototype) const cyclic: Record = {} cyclic.self = cyclic + const foreignExotics = runInNewContext(`(() => { + class Box { constructor() { this.value = 1 } } + class List extends Array {} + return [new Box(), new List(1)] + })()`) as [object, unknown[]] expect(snapshotJsonValue(new ExoticObject())).toBeUndefined() expect(snapshotJsonValue(new Map([['value', 1]]))).toBeUndefined() expect(snapshotJsonValue(new ExoticArray(1))).toBeUndefined() + expect(snapshotJsonValue(foreignExotics[0])).toBeUndefined() + expect(snapshotJsonValue(foreignExotics[1])).toBeUndefined() expect(snapshotJsonValue(sparse)).toBeUndefined() + expect(snapshotJsonValue(compensatedSparse)).toBeUndefined() + expect(snapshotJsonValue(decorated)).toBeUndefined() + expect(snapshotJsonValue(symbolDecorated)).toBeUndefined() + expect(snapshotJsonValue(hiddenObject)).toBeUndefined() + expect(snapshotJsonValue(symbolObject)).toBeUndefined() + expect(snapshotJsonValue(customPrototypeObject)).toBeUndefined() + expect(snapshotJsonValue(forgedIntrinsicObject)).toBeUndefined() + expect(snapshotJsonValue(revokedIntrinsicObject)).toBeUndefined() + expect(snapshotJsonValue(forgedArray)).toBeUndefined() expect(snapshotJsonValue(cyclic)).toBeUndefined() expect(snapshotJsonValue([undefined])).toBeUndefined() expect(snapshotJsonValue({ value: undefined })).toBeUndefined() @@ -133,16 +206,40 @@ describe('isJsonValue', () => { expect(isJsonValue(nullPrototype)).toBe(true) }) - it('rejects sparse arrays, invalid children, exotic objects, and cycles', () => { + it('rejects sparse or decorated arrays, invalid children, exotic objects, and cycles', () => { class Exotic { readonly value = 1 } class ExoticArray extends Array {} const sparse = new Array(1) + const compensatedSparse = new Array(1) + Object.defineProperty(compensatedSparse, 'extra', { value: true }) + const decorated = Object.assign([1], { extra: true }) + const symbolDecorated = [1] + Object.defineProperty(symbolDecorated, Symbol('extra'), { value: true }) + const hiddenObject = Object.defineProperty({}, 'hidden', { value: true }) + const symbolObject = { [Symbol('extra')]: true } + const customPrototype = Object.create(null) as Record + const customPrototypeObject = Object.assign(Object.create(customPrototype) as Record, { value: 1 }) + const forgedIntrinsicObject = objectWithForgedIntrinsicPrototype() + const revokedIntrinsicObject = objectWithForgedIntrinsicPrototype(true) + const forgedPrototype: unknown[] = [] + Object.setPrototypeOf(forgedPrototype, null) + const forgedArray = [1] + Object.setPrototypeOf(forgedArray, forgedPrototype) const cyclic: Record = {} cyclic.self = cyclic expect(isJsonValue(sparse)).toBe(false) + expect(isJsonValue(compensatedSparse)).toBe(false) + expect(isJsonValue(decorated)).toBe(false) + expect(isJsonValue(symbolDecorated)).toBe(false) + expect(isJsonValue(hiddenObject)).toBe(false) + expect(isJsonValue(symbolObject)).toBe(false) + expect(isJsonValue(customPrototypeObject)).toBe(false) + expect(isJsonValue(forgedIntrinsicObject)).toBe(false) + expect(isJsonValue(revokedIntrinsicObject)).toBe(false) + expect(isJsonValue(forgedArray)).toBe(false) expect(isJsonValue(new ExoticArray(1))).toBe(false) expect(isJsonValue([undefined])).toBe(false) expect(isJsonValue({ value: undefined })).toBe(false) diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 823060f728..d752a84bba 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)). @@ -37,14 +37,14 @@ The live registry pipeline has three transformable waterfalls followed by the ob ### Key types -- `ToolDefinition` — `ToolSchema` + `execute(args, exec)`, whose async work must cooperatively stop through `exec.signal`, plus optional presentation callbacks, cooperative `timeoutMs`, and optional per-call `isConcurrencySafe(args)` classification. +- `ToolDefinition` — `ToolSchema` + mandatory `output { schema, render, presentationMeta? }` + `execute(args, exec)`, optional presentation callbacks, cooperative `timeoutMs`, and optional per-call `isConcurrencySafe(args)` classification. A body returns only the canonical JSON value declared by the output schema and cooperatively stops through `exec.signal`. - `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, signal, agent?, parent? }`; `signal` is required and readonly, callers may pass an enclosing execution's opaque token as `parent`, and callers never choose the new execution's own token. - `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary. - `ToolExecution` — the readonly pipeline view: immutable `{ token, callId, name, arguments, signal, agent?, parent? }`; the registry separately retains and re-fuses the original caller signal. `ToolDispatchExecution` is the `tools/execute`-only view whose required signal is mutable, so a wrapper may replace and restore it but cannot delete it. A nested call's `parent` is a `ToolExecutionToken`, not an execution object. - `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws or cancellation wins; it never injects immediately. -- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ content, isError, error?, additionalContexts?, meta? }`. Call identity stays on the immutable `ToolExecution` supplied alongside the result instead of being duplicated on the outcome. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text. `additionalContexts` preserves each deferred or post-execute `HookContext` with its own source and durable JSON metadata; the loop buffers the array and appends each entry as a `context/message` after all `tool/result`s in the step. +- `ToolExecutionResult` — discriminated execution-local outcome. Success is `{ isError:false, value:JsonValue, content, meta?, additionalContexts? }`; failure is `{ isError:true, error:{ message, info? }, content, meta?, additionalContexts? }` and has no value. Call identity stays on the immutable `ToolExecution`. The registry snapshots, validates, and freezes the canonical value before rendering, then materializes the durable presentation fields before final observation. `ToolFailure.info` carries an internal `{ name, code }` for a `HarnessError`; `additionalContexts` preserves every deferred or post-execute `HookContext` for the loop's post-result FIFO. - `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny. -- `PostToolDecision` — `{kind:'accept', content?, additionalContexts?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContexts?}` (turn it into an `isError` whose content is the corrective feedback). Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision. +- `PostToolDecision` — accept may replace `content` or `value`, never both, and may attach `additionalContexts`; block turns feedback into a valueless failure. Content replacement preserves the canonical value and metadata. Value replacement is revalidated and rerenders content/metadata. Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision. - `ToolGuard` — `(execution) => string | undefined`; the returned string is a final monotonic denial reason evaluated after the reorderable pre-execute waterfall and before dispatch. - `ToolCallView` / `ToolResultView` — provider-neutral `card`-tagged render intents a tool returns from `presentCall` / `presentResult` to own how a UI renders ITS calls (see "Tool-owned UI presentation"). @@ -52,8 +52,8 @@ The live registry pipeline has three transformable waterfalls followed by the ob - Tool plugins call `ctx.tools.register()` — schemas flow into the assembly automatically. - `tools/pre-execute` is the reorderable allow/deny/ask gate; `ctx.tools.guard()` adds monotonic owner policy after it. -- `tools/execute` wraps normalized core dispatch for timeout, retry, or metrics. Wrappers may replace only the operational signal. -- `tools/post-execute` may replace content, block with feedback, or attach ordered contexts; `tools/result` observes the immutable final outcome. +- `tools/execute` wraps normalized canonical dispatch for timeout, retry, or metrics. Wrappers may replace only the operational signal; a wrapper-authored success is normalized through the resolved tool's output declaration. Canonical-result provenance belongs to one immutable dispatch token, so a cached result from another call or tool is revalidated under the active declaration. +- `tools/post-execute` may replace presentation content, replace the canonical value, block with feedback, or attach ordered contexts; `tools/result` observes the immutable final outcome. Content replacement is not a confidentiality boundary: block or replace the value when programmatic consumers must not receive it. - Exact signatures and ordering live in the generated [event catalog](../../../docs/cordis-catalog/events.md) and [pipeline](../../../docs/tool-execution-pipeline.md). - MCP servers: one plugin per server, discover tools, call `ctx.tools.register()` with the server's schemas. @@ -76,27 +76,30 @@ ctx.tools.register(defineTool({ offset: { type: 'number' }, limit: { type: 'number' }, }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, async execute(args, exec) { // args is typed: { path: string; offset?: number; limit?: number } - const text = await readFile(args.path, { encoding: 'utf8', signal: exec.signal }) - return [{ type: 'text', text }] + return readFile(args.path, { encoding: 'utf8', signal: exec.signal }) }, })) ``` -The helper converts the author-facing `SchemaSpec` (with `required: true` as a per-property boolean) to standard JSON Schema for the wire format and uses the same typed spec for execute/presentation validation. Raw JSON-Schema tool definitions (from MCP servers) are still accepted by the registry directly. +The unified schema DSL uses `ParameterSchemaSpec` for the implicit open parameter object and `ValueSchemaSpec` for any JSON-value root. It supports `string`, `number`, `integer`, `boolean`, `null`, `array`, `object`, author-only `json`, and exact-one `oneOf`; scalar `enum`/`const` values are type-correct. Every explicit DSL object declares `additionalProperties: true | false`, while the implicit parameter root and raw JSON Schema keep the standard open default. Schema records accept only own enumerable string keys, and schema arrays must be dense ordinary arrays. Compilation, validation, registry detachment, and schema-to-TypeScript rendering use explicit work stacks, so runtime processing of valid deep schemas is memory-bounded rather than call-stack-bounded; `InferValue` preserves exact types through 16 container levels and then falls back to `JsonValue` so TypeScript itself remains stack-safe. -A `defineTool` definition validates model arguments before execution and turns missing required values, wrong primitives, invalid enum members, and nested violations into `ToolArgsError` (`INVALID_ARGS`) for the normal error-result path. Extra keys are allowed, defaults are not applied, and object or array fields without `properties` or `items` receive only a type check. Raw-registered tools own their validation. +A `defineTool` definition validates model arguments before execution and turns missing required values, wrong primitives, invalid enum members, and nested violations into `ToolArgsError` (`INVALID_ARGS`) for the normal error-result path. It also infers the body return and pure output projectors from `output.schema`; the registry snapshots and validates the returned lossless JSON before presentation. The implicit parameter root is open; an explicit object accepts extra keys only with `additionalProperties: true`, and a closed object with no declared properties accepts only `{}`. Raw JSON Schema objects remain open unless they explicitly set `additionalProperties: false`. Defaults are not applied; open objects without `properties` and arrays without `items` receive only a container type check. Raw-registered tools own input validation but still declare and receive registry-enforced output. -See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, and `schemaSpecToJsonSchema` in the public API for details. +See `defineTool`, `validateArgs`, `ToolArgsError`, `ValueSchemaSpec`, `ParameterSchemaSpec`, `InferValue`, `InferArgs`, `valueSchemaSpecToJsonSchema`, and `parameterSchemaSpecToJsonSchema` in the public API for details. Optional `timeoutMs` must be positive and finite; it is policy metadata, not model-visible schema. Optional `isConcurrencySafe(args)` receives typed, softly validated arguments. Exact `true` permits concurrent dispatch/body execution; invalid input and all other outcomes remain exclusive. Opted-in bodies do not mutate parent-owned state, and shared-state races must commute or fail closed. The [parallel tool-call Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md) owns the full safety contract. -### Structured-output schema subset +### Enforced raw JSON Schema subset -`StructuredOutputSchema` is the object-rooted raw JSON Schema subset used by subagents and workflows for machine-readable results. It accepts one scalar `type`, object `properties`/`required`/boolean `additionalProperties`, array `items`, and scalar `enum`/`const`. The annotations `description`, `title`, `default`, and `examples` are ignored but must remain JSON data. Type arrays, undeclared required keys, and unsupported keywords fail through `OutputSchemaError` rather than being ignored; `validateStructuredValue()` returns path-qualified violations without throwing. +`JsonSchemaNode` is the raw counterpart shared by tool outputs, Code Mode generation, subagents, and workflows. It permits any JSON root, an annotation-only unconstrained JSON node, and exact-one `oneOf`; annotations must remain lossless JSON. `assertSupportedJsonSchema()` rejects unsupported constructs, while `validateJsonSchemaValue()` returns path-qualified violations. Subagents and workflows retain their caller-defined object-root requirement through `assertObjectJsonSchema()` and `ObjectJsonSchema`, not through a limitation in the shared vocabulary. ### Tool-owned UI presentation @@ -105,15 +108,16 @@ Tools optionally own pure `presentCall()` and `presentResult()` render intents, - Call views are `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`, `{ card: 'terminal', title, description?, cwd? }`, or `{ card: 'diff', title, diffs, locations? }`. - Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, or `{ card: 'diff', title?, diffs }`. -Returning `undefined` selects generic fallback. Presenters depend only on their arguments because UIs call them during live streaming and log replay. Result presentation may read JSON-serializable `result.meta`, which persists with the result; `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns the rationale. +Returning `undefined` selects generic fallback. Presenters depend only on their arguments and the durable result because UIs call them during live streaming and log replay. `output.presentationMeta(args, value)` derives JSON metadata for direct surface calls; that metadata persists with `tool/result` and returns to `presentResult`, while the canonical value itself remains execution-local and is never replayed. Nested Code dispatches do not compute metadata. `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [canonical-output Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/presentation split and the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns card vocabulary. ### Code Mode -Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic TypeScript SDK for the current scope; only program output re-enters model context. Each JSON-normalized binding re-enters the complete tool pipeline sequentially with logged correlation to the outer call. Denials reject that binding, ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; failures surface as `CodeRunFailedError`. See the [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`. +Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic TypeScript SDK for the current scope; only the program's outer logs and return value re-enter model context. The SDK declares exact `ToolArgsMap` and `ToolOutputMap` entries for every visible tool, and each binding resolves to the tool's canonical JSON value. Each lossless-JSON binding call re-enters the complete tool pipeline sequentially with logged correlation to the outer call. Denials and other failed results reject with the real program-visible `ToolCallError` carrying only `toolName` and `message`; Native content and internal error codes stay outside the Code contract. Ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; runtime failures surface as `CodeRunFailedError`. See the [Code Mode foundation](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md), [typed-return contract](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md), and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`. -- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, a `declare const tools: {...}` TypeScript declaration of the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) is total: constructs outside the `defineTool` subset degrade to `unknown`, never throw. -- **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized before dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes underlying calls one at a time in submission order), given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A denial reaches the program as a binding rejection, and each sub-call is logged as a `tool/code-dispatch` session event with deterministic id `:code:`; `deriveMessages()` does not surface that event. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/meta even when the program later fails. +- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, `JsonValue`, exact `ToolArgsMap` / `ToolOutputMap`, `ToolName`, the `ToolCallError` declaration, and a mapped `tools` namespace for the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) handles every unified schema construct and degrades unsupported raw constructs to `unknown`, never throwing during prompt assembly. +- **The dispatch bridge** (`run_code`'s execute): every binding call is snapshotted as lossless JSON before dispatch (`undefined`, `BigInt`, cycles, sparse arrays, `-0`, and exotic objects reject that one call), serialized through a per-run queue (even `Promise.all` executes underlying calls one at a time in submission order), given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A success returns the final canonical value after policy; a failure reaches the worker as one message and becomes `ToolCallError(toolName, message)`. Each sub-call is logged as a `tool/code-dispatch` session event with deterministic id `:code:` and a bounded Native-content summary; `deriveMessages()` does not surface that event or persist the value. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/meta even when the program later fails. - **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from. +- **Result boundary**: intermediate binding values cross the worker boundary whole and have no per-binding byte cap. `run_code` returns canonical `{ logs: string[], result?: JsonValue }`; strings render raw, every other present JSON root renders through a stack-safe pretty JSON traversal whose total indentation is capped at ten characters (deeper subtrees stay compact), `null` remains explicit, and absent `result` means the program returned `undefined`. The worker's configurable `maxOutputBytes` (default 64 MiB) applies only to the combined serialized outer log-array, completion-value, or failure-message payloads; fixed result-envelope syntax and presentation whitespace are outside that ledger. Invalid and over-limit completions fail explicitly, and only this outer result is eligible for ordinary spill. ### Parallel execution @@ -148,8 +152,8 @@ Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.m Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program: -- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable. -- A FAILED tool call rejects with an `Error` carrying the tool's error text — `try/catch` it to handle and continue. +- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. +- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue. - Calls execute sequentially, even under `Promise.all`. - Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need. @@ -182,8 +186,8 @@ Append-only; newly visible content follows the reusable request prefix and does - **Concurrency policy is not an event seam** — `executionMode()` reads the resolved tool definition directly; plugins can only declare a classifier on definitions they own. - **`tools/pre-execute` deliberately cannot rewrite `exec.arguments`** — logged and rendered args would desync from what ran; the rewrite design is [a proposed Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md). -- **`defineTool`'s schema DSL is a deliberate subset** — string/number/boolean/object/array with string-only `enum`; `validateArgs` tolerates extra keys and preserves `default` as a model-visible JSON Schema annotation without applying it during validation; dynamic Cordis mounts may supply defaults even though first-party definitions do not, while raw-registered JSON-Schema tools validate their own input. +- **Caller-defined subagent and workflow structured outputs remain object-rooted** — this is a consumer-level guard; the shared schema vocabulary and tool outputs support every JSON root. - **`timeoutMs` on a definition is declarative only** — the registry never enforces deadlines; enforcement requires the `@deepseek-ai/dsh-timeout-policy` wrapper. - **Code Mode is TypeScript-only and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language === 'typescript'`; scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only. -- **Code Mode bindings return text only** — non-text content blocks in a sub-call result collapse to `[ content]` placeholders. +- **Code Mode intermediate values are execution-local and unbounded by bytes** — they cannot be reconstructed from session replay and may exhaust process or worker memory; only the outer `run_code` output has the worker's configurable hard cap. - **`run_code` state is fresh per run** — a persistent REPL-style kernel is rejected for the MVP (cross-call state would be invisible to the log); see [the Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md). diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 01af0e4857..30e85c3ad7 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -6,11 +6,11 @@ */ import { parse } from 'node:path' -import { inspect } from 'node:util' import { CallId, HarnessError } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { CodeBindingFunction, CodeRunResult, CodeRuntime } from '@deepseek-ai/dsh-code-runtime' -import type {} from '@deepseek-ai/dsh-session' +import { snapshotJsonValue } from '@deepseek-ai/dsh-session' +import type { JsonValue } from '@deepseek-ai/dsh-session' import { defineTool } from './schema.ts' import type { ToolDefinition, ToolRegistry } from './index.ts' @@ -62,10 +62,7 @@ export class CodeRunFailedError extends HarnessError { */ const SUMMARY_MAX_CHARS = 200 -/** Bounded inspect for rendering a program's completion value into the model-facing text. */ -const INSPECT_OPTIONS = { depth: 4, maxArrayLength: 100, maxStringLength: 10_000 } as const - -/** Join a result's text blocks; a non-text block becomes a placeholder (an MVP limitation, stated in the SDK instructions). */ +/** Join Native content for the bounded durable sub-dispatch summary; non-text blocks become diagnostic placeholders. */ function textOf(content: ContentBlock[]): string { return content .map((block) => { @@ -88,47 +85,120 @@ function summarize(text: string, cwd: string | undefined): string { } /** - * JSON-normalize one binding call's argument into TWO independent parses of the same canonical - * text: `dispatched` goes to the tool, `logged` to the `tool/code-dispatch` event — identical - * by construction (the runtime's structured-clone boundary is wider than JSON; the session log - * accepts only JSON), and separate objects, so a tool mutating its args can neither desync the - * log from what was dispatched nor re-poison the append. + * Snapshot one binding call's argument as lossless JSON, then snapshot that + * detached value again so dispatch and logging stay independent without + * reintroducing structured-clone's platform-specific nesting limit. */ function jsonNormalizeArgs(value: unknown): { dispatched: unknown; logged: unknown } { - if (value === undefined) { - throw new Error('tool arguments must be JSON-serializable (call the tool with an arguments object, e.g. `{}`)') - } - let text: string | undefined + let snapshot: JsonValue | undefined try { - text = JSON.stringify(value) + snapshot = snapshotJsonValue(value) as JsonValue | undefined } catch (error: unknown) { - throw new Error(`tool arguments must be JSON-serializable: ${error instanceof Error ? error.message : String(error)}`) + throw new Error(`tool arguments must be lossless JSON: ${error instanceof Error ? error.message : String(error)}`) } - // JSON.stringify's lib type claims `string`, but a bare function or symbol - // root really yields `undefined` at runtime — the guard is live. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (text === undefined) throw new Error('tool arguments must be JSON-serializable (got a value JSON cannot represent)') - return { dispatched: JSON.parse(text) as unknown, logged: JSON.parse(text) as unknown } + if (snapshot === undefined) { + throw new Error('tool arguments must be lossless JSON (call the tool with an arguments object, e.g. `{}`)') + } + const logged = snapshotJsonValue(snapshot) + /* v8 ignore next -- snapshot is already a detached lossless JSON value. */ + if (logged === undefined) { + throw new Error('tool arguments could not be detached for durable logging') + } + return { dispatched: snapshot, logged } } -/** Render the program's completion value for the model-facing result text (`''` when the program returned nothing). */ -function renderValue(value: unknown): string { - if (value === undefined) return '' - return typeof value === 'string' ? value : inspect(value, INSPECT_OPTIONS) +/** Two-space JSON presentation, matching the existing shallow `run_code` text contract. */ +const JSON_INDENT = ' ' + +/** + * ECMAScript caps `JSON.stringify`'s `space` string at ten characters. The + * renderer also caps TOTAL indentation there, compacting deeper subtrees, so + * formatted output remains linear in the canonical JSON size. + */ +const MAX_JSON_INDENT_CHARS = 10 + +/** A pending fragment in the iterative JSON presentation traversal. */ +type JsonRenderTask = + | { kind: 'text'; text: string } + | { kind: 'value'; value: JsonValue; depth: number; compact: boolean } + +/** Render one non-string JSON root without recursive traversal or unbounded indentation growth. */ +function renderJsonValue(value: Exclude): string { + const chunks: string[] = [] + const tasks: JsonRenderTask[] = [{ kind: 'value', value, depth: 0, compact: false }] + for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) { + if (task.kind === 'text') { + chunks.push(task.text) + continue + } + + const current = task.value + if (current === null || typeof current === 'boolean' || typeof current === 'number') { + chunks.push(String(current)) + continue + } + if (typeof current === 'string') { + chunks.push(JSON.stringify(current)) + continue + } + + const compact = task.compact || (task.depth + 1) * JSON_INDENT.length > MAX_JSON_INDENT_CHARS + const childDepth = task.depth + 1 + if (Array.isArray(current)) { + chunks.push('[') + if (current.length === 0) { + chunks.push(']') + continue + } + tasks.push({ kind: 'text', text: compact ? ']' : `\n${JSON_INDENT.repeat(task.depth)}]` }) + for (let index = current.length - 1; index >= 0; index--) { + const item = current[index] + /* v8 ignore next -- canonical JsonValue arrays are dense. */ + if (item === undefined) throw new Error('cannot render a sparse JSON array') + tasks.push({ kind: 'value', value: item, depth: childDepth, compact }) + tasks.push({ + kind: 'text', + text: compact + ? index === 0 ? '' : ',' + : `${index === 0 ? '\n' : ',\n'}${JSON_INDENT.repeat(childDepth)}`, + }) + } + continue + } + + const keys = Object.keys(current) + chunks.push('{') + if (keys.length === 0) { + chunks.push('}') + continue + } + tasks.push({ kind: 'text', text: compact ? '}' : `\n${JSON_INDENT.repeat(task.depth)}}` }) + for (let index = keys.length - 1; index >= 0; index--) { + const key = keys[index] + /* v8 ignore next -- the loop is bounded by the captured key count. */ + if (key === undefined) throw new Error('cannot render a missing JSON object key') + const item = current[key] + /* v8 ignore next -- canonical JsonValue records contain no undefined properties. */ + if (item === undefined) throw new Error('cannot render an undefined JSON object property') + tasks.push({ kind: 'value', value: item, depth: childDepth, compact }) + tasks.push({ + kind: 'text', + text: compact + ? `${index === 0 ? '' : ','}${JSON.stringify(key)}:` + : `${index === 0 ? '\n' : ',\n'}${JSON_INDENT.repeat(childDepth)}${JSON.stringify(key)}: `, + }) + } + } + return chunks.join('') } -/** The run_code result's `meta` payload (JSON-serializable; `presentResult` narrows it back). */ -interface RunCodeMeta { - logs: CodeRunResult['logs'] +/** Render one present program completion value for the model-facing result text. */ +function renderValue(value: JsonValue): string { + return typeof value === 'string' ? value : renderJsonValue(value) } -/** Soft-narrow a result `meta` back to {@link RunCodeMeta} (replay may carry older shapes; presentation must not throw). */ -function asRunCodeMeta(meta: unknown): RunCodeMeta | undefined { - if (typeof meta !== 'object' || meta === null) return undefined - const m = meta as Record - if (!Array.isArray(m.logs) || !m.logs.every(log => typeof log === 'string')) return undefined - return m as unknown as RunCodeMeta -} +/** Canonical value returned by the outer Code Mode transport. */ +type RunCodeOutput = { logs: string[]; result?: JsonValue } /** * Build the `run_code` {@link ToolDefinition}: one required `code` parameter, @@ -152,7 +222,22 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => parameters: { code: { type: 'string', required: true, description: 'The program: the body of an async TypeScript function.' }, }, - async execute(args, exec) { + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + logs: { type: 'array', required: true, items: { type: 'string' } }, + result: { type: 'json' }, + }, + }, + render: (_args, value) => { + const rendered = value.result === undefined ? '' : renderValue(value.result) + const parts = [value.logs.join('\n'), rendered].filter(part => part.length > 0) + return [{ type: 'text', text: parts.length > 0 ? parts.join('\n') : '(run_code completed with no output)' }] + }, + }, + async execute(args, exec): Promise { const runtime = requireRuntime() // The run-scoped abort: follows the outer signal in, and fires when the @@ -184,7 +269,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => // would be narrowed away by control flow analysis. const runOver = (): boolean => runController.signal.aborted - const binding = (name: string): CodeBindingFunction => async (rawArgs: unknown): Promise => { + const binding = (name: string): CodeBindingFunction => async (rawArgs: unknown): Promise => { if (runOver()) { throw new Error(`run_code run is over (${String(runController.signal.reason)}); ${name} not dispatched`) } @@ -215,7 +300,9 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => isError: result.isError, resultSummary: summarize(text, exec.agent.session.header.cwd), }) - return { text, isError: result.isError } + return result.isError + ? { isError: true as const, message: result.error.message } + : { isError: false as const, value: result.value } }) // A budget expiry or outer cancel that lands while this call was in // flight already aborted the dispatch; stop the program now rather @@ -223,11 +310,11 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => if (runOver()) { throw new Error(`run_code run is over (${String(runController.signal.reason)}); ${name} result discarded`) } - // A failed tool call REJECTS — real code signals failure by throwing, - // so try/catch and Promise.all short-circuiting behave as models - // expect (the error text is the tool's model-facing result text). - if (outcome.isError) throw new Error(outcome.text) - return outcome.text + // The worker turns a binding rejection into ToolCallError and adds + // only the binding name. Native content and internal error metadata + // stay outside the program-facing failure contract. + if (outcome.isError) throw new Error(outcome.message) + return outcome.value } // Null-prototype + defineProperty, mirroring the worker-side namespace @@ -250,7 +337,11 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => try { result = await runtime.run({ program: args.code, - bindings: [{ global: 'tools', functions }], + bindings: [{ + global: 'tools', + functions, + errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' }, + }], signal: runController.signal, }) } finally { @@ -264,12 +355,9 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => const logsText = result.logs.length > 0 ? `\nCaptured output:\n${result.logs.join('\n')}` : '' throw new CodeRunFailedError(`code run failed (${result.error.kind}): ${result.error.message}${logsText}`) } - const rendered = renderValue(result.value) - const parts = [result.logs.join('\n'), rendered].filter(part => part.length > 0) - const meta: RunCodeMeta = { logs: result.logs } return { - content: [{ type: 'text', text: parts.length > 0 ? parts.join('\n') : '(run_code completed with no output)' }], - meta, + logs: result.logs, + ...result.value !== undefined ? { result: result.value } : {}, } } finally { exec.signal.removeEventListener('abort', onOuterAbort) @@ -282,17 +370,8 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => kind: 'execute', rawInput: args.code, }), - // Title omitted on the result: an update replaces only the fields it - // carries, so the pending card's program title persists through - // completion; the captured output rides as body content. - presentResult: (_args, result) => { - const meta = asRunCodeMeta(result.meta) - if (!meta) return undefined - const output = meta.logs.join('\n') - return { - card: 'generic', - ...output.length > 0 ? { content: [{ type: 'text' as const, text: output }] } : {}, - } - }, + // Deliberately no presentResult: the generic surface fallback keeps this + // title and reads durable result content without duplicating a large raw + // result into the host view payload. }) } diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 11f57995e9..ebefeb2cf2 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -12,40 +12,60 @@ import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' import { assertNever, deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm' import type { Agent, HookContext } from '@deepseek-ai/dsh-agent' import { snapshotJsonValue } from '@deepseek-ai/dsh-session' +import type { JsonValue } from '@deepseek-ai/dsh-session' import type { ToolProviderResult } from '@deepseek-ai/dsh-system-prompt' import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' // Type-only: makes `ctx.get('approval')` resolve to the ApprovalService // augmentation. The seam stays optional at runtime — see `serviceAsk`. import type {} from '@deepseek-ai/dsh-user-approval' import type { ToolCallView, ToolResultView } from './presentation.ts' +import { assertSupportedJsonSchema, validateJsonSchemaValue } from './json-schema.ts' +import type { JsonSchemaNode } from './json-schema.ts' import { createRunCodeTool, RUN_CODE_NAME, SDK_SECTION_ORDER } from './code-mode.ts' import { renderToolsSdk } from './ts-types.ts' +import type { ToolSdkSchema } from './ts-types.ts' export { defineTool, - schemaSpecToJsonSchema, + valueSchemaSpecToJsonSchema, + parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, - type SchemaSpec, - type SchemaProp, - type SchemaType, + type ValueSchemaAnnotations, + type StringValueSchemaSpec, + type NumberValueSchemaSpec, + type IntegerValueSchemaSpec, + type BooleanValueSchemaSpec, + type NullValueSchemaSpec, + type ArrayValueSchemaSpec, + type ObjectValueSchemaSpec, + type JsonValueSchemaSpec, + type OneOfValueSchemaSpec, + type ValueSchemaSpec, + type ParameterPropertySpec, + type ParameterSchemaSpec, + type ParameterJsonSchema, + type InferValue, type InferArgs, type DefineToolOptions, - type JsonSchemaObject, } from './schema.ts' export { - assertSupportedOutputSchema, - validateStructuredValue, - OutputSchemaError, - type StructuredOutputSchema, - type StructuredSchemaNode, - type StructuredSchemaType, - type StructuredScalar, + assertSupportedJsonSchema, + assertObjectJsonSchema, + validateJsonSchemaValue, + JsonSchemaError, + type JsonSchemaNode, + type ObjectJsonSchema, + type JsonSchemaType, + type JsonSchemaScalar, } from './json-schema.ts' +export type { JsonValue } from '@deepseek-ai/dsh-session' + export { CodeRunFailedError, RUN_CODE_NAME } from './code-mode.ts' export { jsonSchemaToTs, renderToolsSdk } from './ts-types.ts' +export { defineContentToolFixture, type ContentToolFixtureOptions } from './testing.ts' // The render-intent vocabulary a tool declares via `presentCall`/`presentResult` // lives in its own UI-facing module; re-export it so `@deepseek-ai/dsh-tools` @@ -124,21 +144,31 @@ declare module 'cordis' { } } -/** Tool output, optionally with lossless-JSON presentation metadata persisted for replay. */ -export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown } +/** Tool-owned canonical output contract used after the body returns a JSON value. */ +export interface ToolOutputDefinition { + /** Raw supported JSON Schema enforced against every successful canonical value. */ + readonly schema: JsonSchemaNode + /** Pure projection from validated arguments and value to Native/model content. */ + render(args: unknown, value: JsonValue): ContentBlock[] + /** Pure replayable presentation projection, computed only for surface calls. */ + presentationMeta?(args: unknown, value: JsonValue): JsonValue +} /** A registered tool: its schema plus the execution function. */ export interface ToolDefinition extends ToolSchema { + /** Mandatory canonical output declaration. */ + readonly output: ToolOutputDefinition /** - * Run one accepted call. Async work must observe or forward `exec.signal` and - * settle only after its owned work reaches quiescence. The registry preserves - * caller cancellation through around-dispatch signal replacement and does - * not abandon this promise, but it cannot hard-kill same-process code. + * Run one accepted call and return only its canonical lossless-JSON value. + * Async work must observe or forward `exec.signal` and settle only after its + * owned work reaches quiescence. The registry preserves caller cancellation + * through around-dispatch signal replacement and does not abandon this + * promise, but it cannot hard-kill same-process code. * @param args - losslessly snapshotted, frozen model arguments. * @param exec - execution identity, cancellation signal, and context deferral. - * @returns model-facing content plus optional private presentation metadata. + * @returns the canonical value declared by `output.schema`. */ - execute(args: unknown, exec: ToolRunContext): Promise + 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 @@ -173,7 +203,7 @@ export interface ToolDefinition extends ToolSchema { presentCall?(args: unknown): ToolCallView | undefined /** * Optional: how to present the COMPLETED state, given the same `args` and the - * `result` (`execute`'s content + whether it errored). Returns a + * durable result projection (`content`, failure state, and optional `meta`). Returns a * {@link ToolResultView}, or `undefined` (or omit the method) to keep the * pending title and render the raw result content. Pure and side-effect-free * for the same replay reason. @@ -183,17 +213,16 @@ export interface ToolDefinition extends ToolSchema { /** The completed outcome handed to {@link ToolDefinition.presentResult}. */ export interface ToolResult { - /** The model-facing content `execute` returned (or the error text on failure). */ + /** The final model-facing content (or the rendered error text on failure). */ content: ContentBlock[] /** Whether the call failed. */ isError: boolean /** - * The tool-private presentation payload the tool attached from `execute` (via - * the object return form), threaded verbatim from the `tool/result` event. - * Opaque (`unknown`); the tool narrows it back to its own shape. Absent when - * the tool attached none. + * The tool-private presentation payload projected by its output declaration + * and threaded verbatim from the `tool/result` event. Absent when the tool + * declared no projector or the call was nested under a composite transport. */ - meta?: unknown + meta?: JsonValue } declare const toolExecutionTokenBrand: unique symbol @@ -325,6 +354,14 @@ export interface ToolErrorInfo { code: string } +/** Canonical failure detail; internal routing information remains optional. */ +export interface ToolFailure { + /** Human-readable failure message without the Native `Error: ` envelope. */ + message: string + /** Internal error class/code used by policy and durable diagnostics. */ + info?: ToolErrorInfo +} + /** * Thrown (internally) when the model requests a tool that isn't registered. * Extends {@link HarnessError} (`code: 'UNKNOWN_TOOL'`) so an unknown-tool @@ -338,30 +375,73 @@ export class ToolNotFoundError extends HarnessError { } } -/** The outcome of one tool call. */ -export interface ToolExecutionResult { - content: ContentBlock[] - isError: boolean - /** - * Set when the call failed with a {@link HarnessError}: machine-routable - * `{ name, code }` for retry/sandbox plugins and replay. The model-facing - * text in `content` is always present; this is extra structure for code. - */ - error?: ToolErrorInfo - /** - * Model-facing context for the next request, separate from this tool result. The loop - * accepts it into the active-batch FIFO, then appends after recorded results even if interrupted. - */ - additionalContexts?: HookContext[] - /** - * The tool-private presentation payload from a successful `execute` (the object - * return form). Threaded onto the `tool/result` session event and back into - * {@link ToolResult} for `presentResult`. Opaque (`unknown`); absent when the - * tool attached none or the call failed. - */ - meta?: unknown +/** Thrown when a tool body or post-policy value violates its declared output. */ +export class ToolOutputError extends HarnessError { + /** Schema/value violations in validation order. */ + readonly violations: string[] + + constructor(toolName: string, violations: string[]) { + super(`tool "${toolName}" returned invalid output: ${violations.join('; ')}`, 'INVALID_TOOL_OUTPUT') + this.name = 'ToolOutputError' + this.violations = violations + } } +/** Convert one projector exception into the canonical invalid-output failure. */ +function projectionError(toolName: string, projector: 'render' | 'presentationMeta', error: unknown): ToolOutputError { + return new ToolOutputError(toolName, [`output.${projector} failed: ${errorMessage(error)}`]) +} + +/** Snapshot one projector result before later durable-result materialization. */ +function snapshotProjection(toolName: string, projector: 'render' | 'presentationMeta', candidate: T): T { + try { + const detached = snapshotJsonValue(candidate) + if (detached === undefined) { + throw new ToolOutputError(toolName, [`output.${projector} returned non-lossless JSON`]) + } + return detached + } catch (error: unknown) { + if (error instanceof ToolOutputError) throw error + throw projectionError(toolName, projector, error) + } +} + +/** Snapshot one body or policy value into the canonical invalid-output failure class. */ +function snapshotToolValue(toolName: string, candidate: unknown): JsonValue { + try { + const detached = snapshotJsonValue(candidate) + if (detached === undefined) throw new ToolOutputError(toolName, ['value is not lossless JSON']) + return detached as JsonValue + } catch (error: unknown) { + if (error instanceof ToolOutputError) throw error + throw new ToolOutputError(toolName, [`value snapshot failed: ${errorMessage(error)}`]) + } +} + +/** Successful canonical tool execution, including its Native/model projection. */ +export interface ToolExecutionSuccess { + readonly isError: false + /** Execution-local canonical value; deliberately omitted from durable events. */ + readonly value: JsonValue + readonly content: ContentBlock[] + readonly error?: never + readonly meta?: JsonValue + readonly additionalContexts?: HookContext[] +} + +/** Failed canonical tool execution; failures never carry a successful value. */ +export interface ToolExecutionFailure { + readonly isError: true + readonly error: ToolFailure + readonly value?: never + readonly content: ContentBlock[] + readonly meta?: JsonValue + readonly additionalContexts?: HookContext[] +} + +/** The discriminated, execution-local outcome of one tool call. */ +export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure + /** * Pre-dispatch decision. `allow` runs the call; `deny` materializes an error; * `ask` runs only after an approval service returns `allowed-once` and otherwise @@ -374,11 +454,12 @@ export type PreToolDecision = | { kind: 'ask'; reason?: string } /** - * Post-dispatch decision: accept or replace content, attach context for the next - * request, or block by turning corrective feedback into an error result. + * Post-dispatch decision: accept, replace one projection, attach context for the + * next request, or block by turning corrective feedback into an error result. */ export type PostToolDecision = - | { kind: 'accept'; content?: ContentBlock[]; additionalContexts?: HookContext[] } + | { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: HookContext[] } + | { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: HookContext[] } | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: HookContext[] } /** @@ -403,6 +484,23 @@ function errorMessage(error: unknown): string { } } +/** Derive one failure message from policy feedback without changing its rendered blocks. */ +function failureMessageFromContent(content: ContentBlock[]): string { + const text = content + .map(block => block.type === 'text' ? block.text : `[${block.type} content]`) + .join('\n') + return text.length > 0 ? text : 'tool result blocked by post-execute policy' +} + +/** Snapshot and freeze one durable tool-result projection or reject lossy data. */ +function materializePresentation(candidate: T): T { + const detached = snapshotJsonValue(candidate) + if (detached === undefined) { + throw new TypeError('tool result must be losslessly JSON-serializable') + } + return deepFreeze(detached) +} + /** Structured `{ name, code }` for a thrown HarnessError, else undefined. */ function errorInfo(error: unknown): ToolErrorInfo | undefined { try { @@ -569,7 +667,7 @@ export class ToolRegistry extends Service { // Regenerate from the calling scope's visible tools in stable order. text: (context) => { this.requireCodeRuntime() - return renderToolsSdk(this.schemas(context.scope).filter(schema => schema.name !== RUN_CODE_NAME)) + return renderToolsSdk(this.sdkSchemas(context.scope)) }, }) } @@ -622,6 +720,13 @@ export class ToolRegistry extends Service { */ register(definition: ToolDefinition): () => void { const name = definition.name + const output = (definition as Partial).output + if (output === undefined || typeof output !== 'object' + || typeof output.render !== 'function' + || (output.presentationMeta !== undefined && typeof output.presentationMeta !== 'function')) { + throw new TypeError(`tool "${name}" must declare output { schema, render, presentationMeta? }`) + } + assertSupportedJsonSchema(output.schema) const timeoutMs = definition.timeoutMs if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) { @@ -755,13 +860,34 @@ export class ToolRegistry extends Service { return [...this.view(scope).visible.values()].map(definition => this.schemaOf(definition, true)) } + /** Project visible callable tools onto the generated Code Mode SDK contract. */ + private sdkSchemas(scope?: ScopeKey): ToolSdkSchema[] { + return [...this.view(scope).visible.values()] + .filter(definition => definition.name !== RUN_CODE_NAME) + .map((definition): ToolSdkSchema => { + const output = snapshotJsonValue(definition.output.schema) + /* v8 ignore next -- registration already validated and retained this schema as lossless JSON. */ + if (output === undefined) { + throw new Error(`tool "${definition.name}" output schema must be lossless JSON before SDK projection`) + } + return { + ...this.schemaOf(definition, true), + output, + } + }) + } + /** Project one definition onto the model-facing schema fields. */ private schemaOf(definition: ToolDefinition, detachParameters: boolean): ToolSchema { const { name, description, parameters } = definition + const detached = detachParameters ? snapshotJsonValue(parameters) : parameters + if (detached === undefined) { + throw new Error(`tool "${name}" parameters must be lossless JSON before schema projection`) + } return { name, description, - parameters: detachParameters ? structuredClone(parameters) : parameters, + parameters: detached, } } @@ -895,10 +1021,11 @@ export class ToolRegistry extends Service { return await next({ kind: 'post-result', exec, - result: { + result: this.materializeFinalResult({ content: [{ type: 'text', text: `Error: ${denialReason}` }], isError: true, - }, + error: { message: denialReason }, + }), }) } if (this.callerCancelled(exec)) { @@ -951,13 +1078,7 @@ export class ToolRegistry extends Service { if (!tool) throw new ToolNotFoundError(exec.name) state.bodyInvoked = true const returned = await tool.execute(exec.arguments, exec) - const content = Array.isArray(returned) ? returned : returned.content - const meta = Array.isArray(returned) ? undefined : returned.meta - const result: ToolExecutionResult = { - content, - isError: false, - ...meta !== undefined ? { meta } : {}, - } + const result = this.createSuccessResult(exec, tool, returned) return isAborted(signal) ? toolAbortedResult(result) : result @@ -984,18 +1105,19 @@ export class ToolRegistry extends Service { carrier, 'tools/execute', mutableExec, () => this.dispatchToolBody(mutableExec), ) + const normalized = this.normalizeDispatchResult(exec, result) const deferredContexts = this.deferredContexts.get(exec) /* v8 ignore next -- dispatch only receives executions minted by this registry's prepare stage */ if (deferredContexts === undefined) throw new Error('tool registry scheduler invariant violated: unprepared execution') const resultWithDeferredContexts: ToolExecutionResult = deferredContexts.length === 0 - ? result - : { - ...result, + ? normalized + : this.markCanonical(exec, { + ...normalized, additionalContexts: [ ...deferredContexts, - ...result.additionalContexts ?? [], + ...normalized.additionalContexts ?? [], ], - } + }) return { kind: 'post-result', result: this.callerCancelled(exec) && !resultWithDeferredContexts.isError @@ -1139,32 +1261,113 @@ export class ToolRegistry extends Service { ) const decisionContexts = decision.additionalContexts ?? [] if (decision.kind === 'block') { - return { + const message = failureMessageFromContent(decision.feedback) + return this.markCanonical(exec, { content: decision.feedback, isError: true, + error: { message }, ...decisionContexts.length > 0 ? { additionalContexts: decisionContexts } : {}, - } + }) + } + if (Object.hasOwn(decision, 'content') && Object.hasOwn(decision, 'value')) { + throw new TypeError('tools/post-execute accept decision cannot replace both value and content') } - // Accept: replace content if supplied, preserve the dispatched outcome, and - // append decision contexts after contexts deferred by the tool body. const additionalContexts = [ ...result.additionalContexts ?? [], ...decisionContexts, ] - return { - ...result, - ...decision.content ? { content: decision.content } : {}, - ...additionalContexts.length > 0 ? { additionalContexts } : {}, + if (Object.hasOwn(decision, 'value')) { + if (result.isError) { + throw new TypeError('tools/post-execute cannot replace the value of a failed result') + } + const tool = this.get(exec.name, exec.agent) + if (tool === undefined) throw new ToolNotFoundError(exec.name) + const replaced = this.createSuccessResult(exec, tool, decision.value) + return this.markCanonical(exec, { + ...replaced, + ...additionalContexts.length > 0 ? { additionalContexts } : {}, + }) } + return this.markCanonical(exec, { + ...result, + ...decision.content !== undefined ? { content: decision.content } : {}, + ...additionalContexts.length > 0 ? { additionalContexts } : {}, + }) + } + + /** Registry-normalized results and the exact dispatch that validated each value. */ + private readonly canonicalResults = new WeakMap() + + /** Mark one registry-normalized result as canonical only for its owning dispatch. */ + private markCanonical(exec: ToolExecution, result: T): T { + this.canonicalResults.set(result, exec.token) + return result + } + + /** Snapshot, validate, render, and optionally project one successful body value. */ + private createSuccessResult(exec: ToolExecution, tool: ToolDefinition, candidate: unknown): ToolExecutionSuccess { + const detached = snapshotToolValue(tool.name, candidate) + const violations = validateJsonSchemaValue(tool.output.schema, detached, 'value') + if (violations.length > 0) throw new ToolOutputError(tool.name, violations) + const value = deepFreeze(detached) + let rendered: ContentBlock[] + try { + rendered = tool.output.render(exec.arguments, value) + } catch (error: unknown) { + throw projectionError(tool.name, 'render', error) + } + const content = snapshotProjection(tool.name, 'render', rendered) + let meta: JsonValue | undefined + if (exec.parent === undefined && tool.output.presentationMeta !== undefined) { + let projected: JsonValue + try { + projected = tool.output.presentationMeta(exec.arguments, value) + } catch (error: unknown) { + throw projectionError(tool.name, 'presentationMeta', error) + } + meta = snapshotProjection(tool.name, 'presentationMeta', projected) + } + return this.markCanonical(exec, this.materializeFinalResult({ + isError: false, + value, + content, + ...meta !== undefined ? { meta } : {}, + }) as ToolExecutionSuccess) + } + + /** Normalize an around-dispatch wrapper's authored result through the owning output contract. */ + private normalizeDispatchResult(exec: ToolExecution, result: ToolExecutionResult): ToolExecutionResult { + if (this.canonicalResults.get(result) === exec.token) return result + if (result.isError) { + return this.markCanonical(exec, { + isError: true, + error: result.error, + content: result.content, + ...result.meta !== undefined ? { meta: result.meta } : {}, + ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {}, + }) + } + const tool = this.get(exec.name, exec.agent) + if (tool === undefined) throw new ToolNotFoundError(exec.name) + const normalized = this.createSuccessResult(exec, tool, result.value) + return this.markCanonical(exec, { + ...normalized, + ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {}, + }) } /** Materialize the authoritative commit outcome once, immediately before `tools/result`. */ private materializeFinalResult(result: ToolExecutionResult): ToolExecutionResult { - const detached = snapshotJsonValue(result) - if (detached === undefined) { - throw new TypeError('tool result must be losslessly JSON-serializable') + const presentation = { + content: result.content, + ...result.meta !== undefined ? { meta: result.meta } : {}, + ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {}, } - return deepFreeze(detached) + if (result.isError) { + return materializePresentation({ isError: true as const, error: result.error, ...presentation }) + } + const detached = materializePresentation({ isError: false as const, ...presentation }) + return deepFreeze({ ...detached, value: result.value }) } } @@ -1175,10 +1378,11 @@ function createExecutionToken(): ToolExecutionToken { function toolErrorResult(error: unknown): ToolExecutionResult { const info = errorInfo(error) + const message = errorMessage(error) return { - content: [{ type: 'text', text: `Error: ${errorMessage(error)}` }], + content: [{ type: 'text', text: `Error: ${message}` }], isError: true, - ...info ? { error: info } : {}, + error: { message, ...info ? { info } : {} }, } } @@ -1226,7 +1430,10 @@ function toolAbortedResult(prior?: ToolExecutionResult): ToolExecutionResult { return { content: [{ type: 'text', text: 'Error: tool call aborted' }], isError: true, - error: { name: 'AbortError', code: TOOL_ABORTED }, + error: { + message: 'tool call aborted', + info: { name: 'AbortError', code: TOOL_ABORTED }, + }, ...additionalContexts.length > 0 ? { additionalContexts } : {}, } } @@ -1237,7 +1444,10 @@ function toolAbortedBeforeDispatchResult(prior?: ToolExecutionResult): ToolExecu return { content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }], isError: true, - error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + error: { + message: 'tool call aborted before dispatch', + info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + }, ...additionalContexts.length > 0 ? { additionalContexts } : {}, } } diff --git a/packages/core/tools/src/json-schema.ts b/packages/core/tools/src/json-schema.ts index e1a0dc43a6..9b6ca88d93 100644 --- a/packages/core/tools/src/json-schema.ts +++ b/packages/core/tools/src/json-schema.ts @@ -1,323 +1,656 @@ /** - * Structured-output JSON Schema subset for subagents and workflows. It supports - * one scalar `type`; object `properties`/`required`/boolean - * `additionalProperties`; array `items`; scalar `enum`/`const`; and JSON-valued - * annotations. Unsupported or misplaced keywords reject rather than being - * accepted without enforcement, and structured-output roots must be objects. + * Enforced JSON Schema subset shared by tool outputs, generated Code Mode + * types, subagents, and workflows. The subset accepts any JSON root, an + * annotation-only schema for unconstrained JSON, one scalar `type`, object + * `properties`/`required`/boolean `additionalProperties`, array `items`, + * type-correct scalar `enum`/`const`, and exact-one `oneOf`. + * + * Unsupported or misplaced keywords reject rather than being accepted without + * enforcement. Consumers that require an object root apply + * {@link assertObjectJsonSchema} at their own boundary. * @module dsh-tools/json-schema */ import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm' +import { isJsonValue, type JsonValue } from '@deepseek-ai/dsh-session' -/** The scalar values `enum`/`const` may carry (finite numbers only). */ -export type StructuredScalar = string | number | boolean | null +/** Scalar JSON values supported by `enum` and `const`. */ +export type JsonSchemaScalar = string | number | boolean | null -/** The `type` keywords the subset accepts. */ -export type StructuredSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null' +/** Single-type keywords accepted by the enforced subset. */ +export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null' + +/** Scalar-only schema types accepted by literal constraints. */ +type JsonSchemaScalarType = Exclude /** - * 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 JsonSchemaType[] = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'] -const SCHEMA_TYPES: readonly StructuredSchemaType[] = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'] - -/** - * Whether a value is a PLAIN JSON object — non-null, non-array, and with a - * prototype chain of at most one link (`null`-proto, or any realm's - * `Object.prototype`, whose own prototype is `null`). Realm-agnostic on - * purpose: a schema materialized in another realm carries THAT realm's - * `Object.prototype`, which an identity check would wrongly reject. Exotic - * hosts (`Date`, `Map`, class instances) have longer chains and are rejected — - * they would serialize lossily (`Date` → string, `Map` → `{}`) instead of - * failing loud. - */ -function isObjectLike(value: unknown): value is Record { - if (typeof value !== 'object' || value === null || Array.isArray(value)) return false - const proto: unknown = Object.getPrototypeOf(value) - return proto === null || Object.getPrototypeOf(proto) === null -} - -/** Whether a value is a supported scalar (`enum`/`const` member): string, finite number, boolean, or null. */ -function isStructuredScalar(value: unknown): value is StructuredScalar { - return value === null || typeof value === 'string' || typeof value === 'boolean' - || (typeof value === 'number' && Number.isFinite(value)) -} - -/** - * Whether a value is JSON data (annotation payloads only): scalars, arrays, and - * object-likes of such values. Realm-agnostic on purpose (no prototype check) — - * the schema may have been materialized from another realm; structural JSON-ness - * is what the wire needs. Cycles are rejected via `seen`. - */ -function isJsonData(value: unknown, seen: Set): boolean { - if (isStructuredScalar(value)) return true - // The scalar check above already returned for null, so `object` here is a real object. - if (typeof value !== 'object') return false - if (seen.has(value)) return false - seen.add(value) +/* jscpd:ignore-start -- this realm boundary mirrors the session-owned lossless-JSON intrinsic test */ +/** Whether a realm-owned intrinsic prototype is backed by its native constructor. */ +function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): boolean { + const descriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor') + const constructor: unknown = descriptor?.value + if (typeof constructor !== 'function') return false try { - if (Array.isArray(value)) return value.every(entry => isJsonData(entry, seen)) - // A non-plain object (Date, Map, class instance) is NOT JSON data even when - // it has no enumerable values — it would serialize lossily, not loudly. - if (!isObjectLike(value)) return false - return Object.values(value).every(entry => isJsonData(entry, seen)) - } finally { - seen.delete(value) + return constructor.name === name + && constructor.prototype === prototype + && Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }` + } catch { + return false } } -/** Collect subset violations for one schema node (recursive walk). */ -function checkSchemaNode(node: unknown, path: string, violations: string[], seen: Set): void { - if (!isObjectLike(node)) { - violations.push(`${path} must be a schema object`) - return - } - if (seen.has(node)) { - violations.push(`${path} is circular`) - return - } - seen.add(node) +/** Whether a candidate is one realm's intrinsic `Object.prototype`. */ +function isIntrinsicObjectPrototype(value: object): boolean { + return Object.getPrototypeOf(value) === null && hasIntrinsicConstructor(value, 'Object') +} - for (const key of Object.keys(node)) { - if (CONSTRAINT_KEYWORDS.has(key)) continue - if (ANNOTATION_KEYWORDS.has(key)) { - if (!isJsonData(node[key], new Set())) violations.push(`${path}.${key} annotation must be JSON data`) +/** + * Test for a realm-agnostic plain JSON record without accepting arrays or + * exotic objects. + * @param value - candidate record from any JavaScript realm. + * @returns Whether the value has a plain-object prototype chain. + */ +export function isPlainJsonRecord(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + try { + const prototype: unknown = Object.getPrototypeOf(value) + return prototype === null + || typeof prototype === 'object' && isIntrinsicObjectPrototype(prototype) + } catch { + return false + } +} + +/** Whether an array uses one realm's intrinsic `Array.prototype`. */ +function hasPlainArrayPrototype(value: unknown[]): boolean { + const prototype: unknown = Object.getPrototypeOf(value) + if (!Array.isArray(prototype) || !hasIntrinsicConstructor(prototype, 'Array')) return false + const objectPrototype: unknown = Object.getPrototypeOf(prototype) + return typeof objectPrototype === 'object' + && objectPrototype !== null + && isIntrinsicObjectPrototype(objectPrototype) +} +/* jscpd:ignore-end */ + +/** Return whether a record contains only own enumerable string keys. */ +function hasOnlyEnumerableStringKeys(value: object): boolean { + try { + return Reflect.ownKeys(value) + .every(key => typeof key === 'string' && Object.prototype.propertyIsEnumerable.call(value, key)) + } catch { + return false + } +} + +/** + * Test for an ordinary schema record whose keys survive JSON projection. + * @param value - candidate record from any JavaScript realm. + * @returns Whether the record has an intrinsic prototype and only own enumerable string keys. + */ +export function isJsonSchemaRecord(value: unknown): value is Record { + return isPlainJsonRecord(value) && hasOnlyEnumerableStringKeys(value) +} + +/** + * Test for a dense ordinary array with no JSON-invisible decorations. + * @param value - candidate array from any JavaScript realm. + * @returns Whether the array is intrinsic, dense, and undecorated. + */ +export function isPlainJsonArray(value: unknown): value is unknown[] { + if (!Array.isArray(value)) return false + try { + if (!hasPlainArrayPrototype(value) || Reflect.ownKeys(value).length !== value.length + 1) return false + for (let index = 0; index < value.length; index++) { + if (!Object.hasOwn(value, index)) return false + } + return true + } catch { + return false + } +} + +/** Lossless finite JSON number, excluding negative zero. */ +function isJsonNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0) +} + +/** Whether a scalar is valid for one declared schema type. */ +function scalarMatches(type: JsonSchemaScalarType, value: unknown): value is JsonSchemaScalar { + switch (type) { + case 'string': return typeof value === 'string' + case 'number': return isJsonNumber(value) + case 'integer': return isJsonNumber(value) && Number.isInteger(value) + case 'boolean': return typeof value === 'boolean' + case 'null': return value === null + /* v8 ignore next -- JsonSchemaScalarType is closed; this retains compile-time exhaustiveness. */ + default: return assertNever(type, 'JsonSchemaType') + } +} + +/** Deferred work for the stack-safe raw-schema walk. */ +type SchemaWalkTask = + | { kind: 'enter'; node: unknown; path: string } + | { kind: 'leave'; node: object } + | { kind: 'one-of-tail'; node: Record; path: string } + | { kind: 'object-tail'; node: Record; path: string; properties: unknown } + +/** Keywords that are invalid beside `oneOf`. */ +const ONE_OF_SIBLING_KEYWORDS = ['properties', 'required', 'additionalProperties', 'items', 'enum', 'const'] as const + +/** Validate object-only fields after its property schemas have been visited. */ +function checkObjectSchemaTail( + node: Record, + path: string, + properties: unknown, + violations: string[], +): void { + const hasRequired = Object.hasOwn(node, 'required') + const required = hasRequired ? node.required : undefined + if (hasRequired) { + if (!isPlainJsonArray(required) || required.some(entry => typeof entry !== 'string')) { + violations.push(`${path}.required must be an array of strings`) + } else { + const declared = isJsonSchemaRecord(properties) ? properties : {} + for (const key of required as string[]) { + if (!Object.hasOwn(declared, key)) violations.push(`${path}.required names "${key}" which is not in properties`) + } + } + } + if (Object.hasOwn(node, 'additionalProperties') && typeof node.additionalProperties !== 'boolean') { + violations.push(`${path}.additionalProperties must be a boolean`) + } +} + +/** Collect every violation for one raw schema tree without using the JavaScript call stack. */ +function checkSchemaNode(root: unknown, rootPath: string, violations: string[], seen: Set): void { + const tasks: SchemaWalkTask[] = [{ kind: 'enter', node: root, path: rootPath }] + for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) { + if (task.kind === 'leave') { + seen.delete(task.node) continue } - violations.push(`${path}.${key} is not a supported keyword (subset: type/properties/required/additionalProperties/items/enum/const + annotations)`) - } - if (typeof node.description !== 'undefined' && typeof node.description !== 'string') { - violations.push(`${path}.description must be a string`) - } - if (typeof node.title !== 'undefined' && typeof node.title !== 'string') { - violations.push(`${path}.title must be a string`) - } - - const type = node.type - if (typeof type !== 'string' || !(SCHEMA_TYPES as readonly unknown[]).includes(type)) { - violations.push(Array.isArray(type) - ? `${path}.type must be a single type string (type arrays are not supported)` - : `${path}.type must be one of ${SCHEMA_TYPES.join('/')}`) - seen.delete(node) - return - } - const schemaType = type as StructuredSchemaType - - // Keywords that only make sense on one type are rejected elsewhere — an - // `items` on an object (or `properties` on a string) is a schema-author bug - // the subset surfaces rather than ignores. - const allowedFor: Record = { - properties: ['object'], - required: ['object'], - additionalProperties: ['object'], - items: ['array'], - enum: ['string', 'number', 'integer', 'boolean', 'null'], - const: ['string', 'number', 'integer', 'boolean', 'null'], - } - for (const [key, types] of Object.entries(allowedFor)) { - if (key in node && !types.includes(schemaType)) { - violations.push(`${path}.${key} is not supported on type "${schemaType}"`) + if (task.kind === 'one-of-tail') { + for (const key of ONE_OF_SIBLING_KEYWORDS) { + if (Object.hasOwn(task.node, key)) violations.push(`${task.path}.${key} is not supported beside oneOf`) + } + continue + } + if (task.kind === 'object-tail') { + checkObjectSchemaTail(task.node, task.path, task.properties, violations) + continue } - } - switch (schemaType) { - case 'object': { - const properties = node.properties - if (properties !== undefined) { - if (!isObjectLike(properties)) { - violations.push(`${path}.properties must be an object of schemas`) - } else { - for (const [key, child] of Object.entries(properties)) { - checkSchemaNode(child, `${path}.properties.${key}`, violations, seen) + const { node, path } = task + if (!isJsonSchemaRecord(node)) { + violations.push(`${path} must be a schema object`) + continue + } + if (seen.has(node)) { + violations.push(`${path} is circular`) + continue + } + seen.add(node) + tasks.push({ kind: 'leave', node }) + + for (const key of Object.keys(node)) { + if (CONSTRAINT_KEYWORDS.has(key)) continue + if (ANNOTATION_KEYWORDS.has(key)) { + try { + if (!isJsonValue(node[key])) violations.push(`${path}.${key} annotation must be lossless JSON data`) + } catch { + violations.push(`${path}.${key} annotation must be lossless JSON data`) + } + continue + } + violations.push(`${path}.${key} is not a supported keyword (subset: type/oneOf/properties/required/additionalProperties/items/enum/const + annotations)`) + } + if (Object.hasOwn(node, 'description') && typeof node.description !== 'string') { + violations.push(`${path}.description must be a string`) + } + if (Object.hasOwn(node, 'title') && typeof node.title !== 'string') { + violations.push(`${path}.title must be a string`) + } + + const hasType = Object.hasOwn(node, 'type') + const hasOneOf = Object.hasOwn(node, 'oneOf') + if (hasType && hasOneOf) { + violations.push(`${path} cannot declare both type and oneOf`) + continue + } + if (!hasType && !hasOneOf) { + for (const key of ONE_OF_SIBLING_KEYWORDS) { + if (Object.hasOwn(node, key)) violations.push(`${path}.${key} requires type or oneOf`) + } + continue + } + + if (hasOneOf) { + const oneOf = node.oneOf + tasks.push({ kind: 'one-of-tail', node, path }) + if (!isPlainJsonArray(oneOf) || oneOf.length < 2) { + violations.push(`${path}.oneOf must be an array of at least two schemas`) + } else { + for (let index = oneOf.length - 1; index >= 0; index--) { + tasks.push({ kind: 'enter', node: oneOf[index], path: `${path}.oneOf[${index}]` }) + } + } + continue + } + + const type = node.type + if (typeof type !== 'string' || !(SCHEMA_TYPES as readonly unknown[]).includes(type)) { + violations.push(Array.isArray(type) + ? `${path}.type must be a single type string (type arrays are not supported)` + : `${path}.type must be one of ${SCHEMA_TYPES.join('/')}`) + continue + } + const schemaType = type as JsonSchemaType + const allowedFor: Record = { + properties: ['object'], + required: ['object'], + additionalProperties: ['object'], + items: ['array'], + enum: ['string', 'number', 'integer', 'boolean', 'null'], + const: ['string', 'number', 'integer', 'boolean', 'null'], + } + for (const [key, types] of Object.entries(allowedFor)) { + if (Object.hasOwn(node, key) && !types.includes(schemaType)) { + violations.push(`${path}.${key} is not supported on type "${schemaType}"`) + } + } + + switch (schemaType) { + case 'object': { + const properties = Object.hasOwn(node, 'properties') ? node.properties : undefined + tasks.push({ kind: 'object-tail', node, path, properties }) + if (Object.hasOwn(node, 'properties')) { + if (!isJsonSchemaRecord(properties)) { + violations.push(`${path}.properties must be an object of schemas`) + } else { + const entries = Object.entries(properties) + for (let index = entries.length - 1; index >= 0; index--) { + const entry = entries[index] + /* v8 ignore next -- the loop is bounded by the captured entry count. */ + if (entry === undefined) continue + tasks.push({ kind: 'enter', node: entry[1], path: `${path}.properties.${entry[0]}` }) + } } } + break } - const required = node.required - if (required !== undefined) { - if (!Array.isArray(required) || required.some(entry => typeof entry !== 'string')) { - violations.push(`${path}.required must be an array of strings`) - } else { - const declared = isObjectLike(properties) ? properties : {} - // The guard above proved every entry is a string. - for (const key of required as string[]) { - // Own-property check: `in` would let inherited names (`toString`) - // satisfy the declared-in-properties contract via the prototype. - if (!Object.hasOwn(declared, key)) violations.push(`${path}.required names "${key}" which is not in properties`) + case 'array': { + if (Object.hasOwn(node, 'items')) tasks.push({ kind: 'enter', node: node.items, path: `${path}.items` }) + break + } + case 'string': + case 'number': + case 'integer': + case 'boolean': + case 'null': { + const hasEnum = Object.hasOwn(node, 'enum') + const allowed = hasEnum ? node.enum : undefined + const enumValid = isPlainJsonArray(allowed) + && allowed.length > 0 + && allowed.every(entry => scalarMatches(schemaType, entry)) + if (hasEnum && !enumValid) { + violations.push(`${path}.enum must be a non-empty array of ${schemaType} values`) + } + const hasConst = Object.hasOwn(node, 'const') + const declaredConst = hasConst ? node.const : undefined + const constValid = scalarMatches(schemaType, declaredConst) + if (hasConst) { + if (!constValid) { + violations.push(`${path}.const must be a ${schemaType} value`) + } else if (enumValid && !allowed.includes(declaredConst)) { + violations.push(`${path}.const must be one of ${path}.enum when both are declared`) } } + break } - if (node.additionalProperties !== undefined && typeof node.additionalProperties !== 'boolean') { - violations.push(`${path}.additionalProperties must be a boolean`) - } - break + /* v8 ignore next -- schemaType was narrowed from the closed SCHEMA_TYPES table above. */ + default: assertNever(schemaType, 'JsonSchemaType') } - case 'array': { - if (node.items !== undefined) checkSchemaNode(node.items, `${path}.items`, violations, seen) - break - } - case 'string': - case 'number': - case 'integer': - case 'boolean': - case 'null': { - const allowed = node.enum - if (allowed !== undefined) { - if (!Array.isArray(allowed) || allowed.length === 0 || !allowed.every(entry => isStructuredScalar(entry))) { - violations.push(`${path}.enum must be a non-empty array of scalars`) - } - } - if ('const' in node && !isStructuredScalar(node.const)) { - violations.push(`${path}.const must be a scalar`) - } - break - } - /* v8 ignore start -- defensive: schemaType was membership-checked against SCHEMA_TYPES above, so no runtime value reaches here */ - default: - assertNever(schemaType, 'assertSupportedOutputSchema') - /* v8 ignore stop */ } - - seen.delete(node) } /** - * Assert `schema` is a supported {@link StructuredOutputSchema} — object-rooted - * and entirely within the enforced subset. Throws {@link OutputSchemaError} - * (`UNSUPPORTED_SCHEMA`) listing EVERY violation; returns (and narrows) on - * success. Call this at the seam boundary, before any child is created. - * @param schema - the caller-supplied schema (unknown until asserted). - * @returns nothing — the assertion signature narrows `schema` to - * {@link StructuredOutputSchema} in the caller's scope on normal return. + * Assert that an arbitrary raw schema uses only the enforced subset. + * Annotation-only schemas are accepted as the standard unconstrained-JSON + * form; callers that require an object root use {@link assertObjectJsonSchema}. + * @param schema - untrusted raw JSON Schema. + * @returns Assertion that the schema belongs to the supported subset. */ -export function assertSupportedOutputSchema(schema: unknown): asserts schema is StructuredOutputSchema { +export function assertSupportedJsonSchema(schema: unknown): asserts schema is JsonSchemaNode { const violations: string[] = [] checkSchemaNode(schema, 'schema', violations, new Set()) - if (violations.length === 0 && (schema as StructuredSchemaNode).type !== 'object') { - violations.push('schema.type must be "object" (structured output is object-rooted)') - } - if (violations.length > 0) throw new OutputSchemaError(violations) + if (violations.length > 0) throw new JsonSchemaError(violations) } -/** Collect violations for one value against an (already asserted) schema node. */ -function checkValue(node: StructuredSchemaNode, value: unknown, path: string): string[] { - switch (node.type) { - case 'object': { - if (!isObjectLike(value)) return [`"${path}" must be an object`] - const violations: string[] = [] - const properties = node.properties ?? {} - // Own-property discipline throughout: JSON carries own enumerable - // properties only, so an inherited `toString` must not satisfy - // `required`, dodge `additionalProperties: false`, or be validated as if - // the value carried it. - for (const key of node.required ?? []) { - if (!Object.hasOwn(value, key) || value[key] === undefined) violations.push(`missing required property "${path}.${key}"`) - } - for (const [key, child] of Object.entries(properties)) { - if (!Object.hasOwn(value, key) || value[key] === undefined) continue - violations.push(...checkValue(child, value[key], `${path}.${key}`)) - } - if (node.additionalProperties === false) { - for (const key of Object.keys(value)) { - if (!Object.hasOwn(properties, key)) violations.push(`"${path}.${key}" is not a declared property (additionalProperties: false)`) - } - } - return violations - } - case 'array': { - if (!Array.isArray(value)) return [`"${path}" must be an array`] - if (!node.items) return [] - const items = node.items - return value.flatMap((entry, index) => checkValue(items, entry, `${path}[${index}]`)) - } - case 'string': { - if (typeof value !== 'string') return [`"${path}" must be a string`] - break - } - case 'number': { - if (typeof value !== 'number' || !Number.isFinite(value)) return [`"${path}" must be a finite number`] - break - } - case 'integer': { - if (typeof value !== 'number' || !Number.isInteger(value)) return [`"${path}" must be an integer`] - break - } - case 'boolean': { - if (typeof value !== 'boolean') return [`"${path}" must be a boolean`] - break - } - case 'null': { - if (value !== null) return [`"${path}" must be null`] - break - } - default: - return assertNever(node.type, 'validateStructuredValue') +/** + * Assert the enforced subset plus the object-root constraint retained by + * subagent and workflow structured outputs. + * @param schema - untrusted caller-supplied schema. + * @returns Assertion that the schema belongs to the supported subset and has an object root. + */ +export function assertObjectJsonSchema(schema: unknown): asserts schema is ObjectJsonSchema { + const violations: string[] = [] + checkSchemaNode(schema, 'schema', violations, new Set()) + if (violations.length === 0 + && (!isJsonSchemaRecord(schema) || !Object.hasOwn(schema, 'type') || schema.type !== 'object')) { + violations.push('schema.type must be "object" (structured output is object-rooted)') } - // Scalar constraint checks, shared by every scalar branch above. - if (node.enum && !node.enum.includes(value)) { - return [`"${path}" must be one of ${JSON.stringify(node.enum)}`] + if (violations.length > 0) throw new JsonSchemaError(violations) +} + +/** Safely test the lossless JSON boundary when a getter may throw. */ +function safelyIsJsonValue(value: unknown): boolean { + try { + return isJsonValue(value) + } catch { + return false } - if ('const' in node && value !== node.const) { - return [`"${path}" must be ${JSON.stringify(node.const)}`] +} + +/** Root-aware diagnostic path for the parameter validator's empty sentinel. */ +function diagnosticPath(path: string): string { + return path === '' ? 'arguments' : path +} + +/** Append one object property without a leading dot at an implicit root. */ +function propertyPath(path: string, key: string): string { + return path === '' ? key : `${path}.${key}` +} + +/** One child evaluation deferred by a container or exact-one union frame. */ +interface ValueChild { + readonly node: JsonSchemaNode + readonly value: unknown + readonly path: string +} + +/** Explicit call frame for stack-safe schema-value validation. */ +interface ValueFrame { + readonly node: JsonSchemaNode + readonly value: unknown + readonly path: string + catches: boolean + phase: 'start' | 'children' + kind?: 'oneOf' | 'object' | 'array' + children: ValueChild[] + childIndex: number + violations: string[] + tailViolations: string[] + matches: number +} + +/** The generic exception-containment diagnostic owned by one valid schema node. */ +function losslessValueViolation(path: string): string[] { + return [`"${diagnosticPath(path)}" must be a lossless JSON value`] +} + +/** Append diagnostics without spreading a potentially wide child result as call arguments. */ +function appendViolations(target: string[], source: readonly string[]): void { + for (const violation of source) target.push(violation) +} + +/** Initialize one validation frame with empty aggregation state. */ +function valueFrame(node: JsonSchemaNode, value: unknown, path: string): ValueFrame { + return { + node, + value, + path, + catches: false, + phase: 'start', + children: [], + childIndex: 0, + violations: [], + tailViolations: [], + matches: 0, + } +} + +/** Validate one scalar node after its primitive type check. */ +function checkScalarValue(node: JsonSchemaNode, value: unknown, path: string): string[] { + const allowed = Object.hasOwn(node, 'enum') ? node.enum : undefined + if (allowed !== undefined && !allowed.includes(value as JsonSchemaScalar)) { + return [`"${diagnosticPath(path)}" must be one of ${JSON.stringify(allowed)}`] + } + if (Object.hasOwn(node, 'const') && value !== node.const) { + return [`"${diagnosticPath(path)}" must be ${JSON.stringify(node.const)}`] } return [] } -/** - * Validate a value against an (already {@link assertSupportedOutputSchema}- - * asserted) schema. Returns human-readable, path-qualified violation messages - * — empty means valid. Total: never throws, however malformed the value. - * @param schema - the asserted schema to check against. - * @param value - the candidate value (e.g. parsed tool-call arguments). - * @returns every violation found, in walk order (empty = valid). - */ -export function validateStructuredValue(schema: StructuredOutputSchema, value: unknown): string[] { - return checkValue(schema, value, 'value') +/** Validate one trusted schema/value pair with explicit frames rather than recursive calls. */ +function checkValue(schema: JsonSchemaNode, value: unknown, path: string): string[] { + const frames: ValueFrame[] = [valueFrame(schema, value, path)] + let rootResult: string[] | undefined + + const receive = (result: string[]): void => { + const parent = frames.at(-1) + if (parent === undefined) { + rootResult = result + return + } + if (parent.kind === 'oneOf') { + if (result.length === 0) parent.matches++ + } else { + appendViolations(parent.violations, result) + } + } + const finish = (result: string[]): void => { + frames.pop() + receive(result) + } + + while (frames.length > 0) { + const frame = frames.at(-1) + /* v8 ignore next -- the loop condition guarantees a current frame. */ + if (frame === undefined) break + try { + if (frame.phase === 'children') { + if (frame.childIndex < frame.children.length) { + const child = frame.children[frame.childIndex] + /* v8 ignore next -- childIndex is bounded by children.length. */ + if (child === undefined) throw new Error('missing schema-value child frame') + frame.childIndex++ + frames.push(valueFrame(child.node, child.value, child.path)) + continue + } + if (frame.kind === 'oneOf') { + finish(frame.matches === 1 ? [] : [`"${diagnosticPath(frame.path)}" must match exactly one oneOf branch (matched ${frame.matches})`]) + continue + } + appendViolations(frame.violations, frame.tailViolations) + if (frame.violations.length > 0) { + finish(frame.violations) + } else if (frame.kind === 'object') { + finish(safelyIsJsonValue(frame.value) ? [] : [`"${diagnosticPath(frame.path)}" must be a lossless JSON object`]) + } else { + finish(safelyIsJsonValue(frame.value) ? [] : [`"${diagnosticPath(frame.path)}" must be a dense lossless JSON array`]) + } + continue + } + + const nodeType = Object.hasOwn(frame.node, 'type') ? frame.node.type : undefined + frame.catches = !(nodeType !== undefined && !(SCHEMA_TYPES as readonly unknown[]).includes(nodeType)) + const oneOf = Object.hasOwn(frame.node, 'oneOf') ? frame.node.oneOf : undefined + if (oneOf !== undefined) { + frame.kind = 'oneOf' + frame.children = Array.from(oneOf, branch => ({ node: branch, value: frame.value, path: frame.path })) + frame.childIndex = 0 + frame.matches = 0 + frame.phase = 'children' + continue + } + if (nodeType === undefined) { + finish(safelyIsJsonValue(frame.value) ? [] : losslessValueViolation(frame.path)) + continue + } + + switch (nodeType) { + case 'object': { + if (!isPlainJsonRecord(frame.value)) { + finish([`"${diagnosticPath(frame.path)}" must be an object`]) + break + } + const properties = Object.hasOwn(frame.node, 'properties') ? frame.node.properties ?? {} : {} + const violations: string[] = [] + const required = Object.hasOwn(frame.node, 'required') ? frame.node.required ?? [] : [] + for (const key of required) { + if (!Object.hasOwn(frame.value, key) || frame.value[key] === undefined) { + violations.push(`missing required property "${propertyPath(frame.path, key)}"`) + } + } + const children: ValueChild[] = [] + for (const [key, child] of Object.entries(properties)) { + if (!Object.hasOwn(frame.value, key) || frame.value[key] === undefined) continue + children.push({ node: child, value: frame.value[key], path: propertyPath(frame.path, key) }) + } + const tailViolations: string[] = [] + if (Object.hasOwn(frame.node, 'additionalProperties') && frame.node.additionalProperties === false) { + for (const key of Object.keys(frame.value)) { + if (!Object.hasOwn(properties, key)) { + tailViolations.push(`"${propertyPath(frame.path, key)}" is not a declared property (additionalProperties: false)`) + } + } + } + frame.kind = 'object' + frame.children = children + frame.childIndex = 0 + frame.violations = violations + frame.tailViolations = tailViolations + frame.phase = 'children' + break + } + case 'array': { + if (!Array.isArray(frame.value)) { + finish([`"${diagnosticPath(frame.path)}" must be an array`]) + break + } + const items = Object.hasOwn(frame.node, 'items') ? frame.node.items : undefined + const children = items === undefined + ? [] + : frame.value.flatMap((entry, index): ValueChild[] => [{ node: items, value: entry, path: `${frame.path}[${index}]` }]) + frame.kind = 'array' + frame.children = children + frame.childIndex = 0 + frame.violations = [] + frame.phase = 'children' + break + } + case 'string': + finish(typeof frame.value === 'string' + ? checkScalarValue(frame.node, frame.value, frame.path) + : [`"${diagnosticPath(frame.path)}" must be a string`]) + break + case 'number': + finish(typeof frame.value !== 'number' + ? [`"${diagnosticPath(frame.path)}" must be a number`] + : !isJsonNumber(frame.value) + ? [`"${diagnosticPath(frame.path)}" must be a finite JSON number`] + : checkScalarValue(frame.node, frame.value, frame.path)) + break + case 'integer': + finish(!isJsonNumber(frame.value) || !Number.isInteger(frame.value) + ? [`"${diagnosticPath(frame.path)}" must be an integer`] + : checkScalarValue(frame.node, frame.value, frame.path)) + break + case 'boolean': + finish(typeof frame.value === 'boolean' + ? checkScalarValue(frame.node, frame.value, frame.path) + : [`"${diagnosticPath(frame.path)}" must be a boolean`]) + break + case 'null': + finish(frame.value === null + ? checkScalarValue(frame.node, frame.value, frame.path) + : [`"${diagnosticPath(frame.path)}" must be null`]) + break + default: + finish(assertNever(nodeType, 'JsonSchemaType')) + } + } catch (error) { + let failed = frames.pop() + while (failed !== undefined && !failed.catches) failed = frames.pop() + if (failed === undefined) throw error + receive(losslessValueViolation(failed.path)) + } + } + + /* v8 ignore next -- every root frame finishes or throws. */ + return rootResult ?? losslessValueViolation(path) +} + +/** + * Validate a candidate value against an asserted raw schema. The function is + * total for arbitrary values and returns path-qualified violations. + * @param schema - a schema accepted by {@link assertSupportedJsonSchema}. + * @param value - the candidate JSON value. + * @param path - root label used in diagnostics. + * @returns All violations in walk order; empty means valid. + */ +export function validateJsonSchemaValue(schema: JsonSchemaNode, value: unknown, path = 'value'): string[] { + return checkValue(schema, value, path) } diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts index f2b62669b1..ce218bfba4 100644 --- a/packages/core/tools/src/schema.ts +++ b/packages/core/tools/src/schema.ts @@ -1,173 +1,465 @@ -/** Typed tool-parameter DSL with argument inference and JSON Schema output. @module dsh-tools/schema */ +/** Unified JSON-value schema DSL, inference, compilation, and typed tool helper. @module dsh-tools/schema */ -import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm' -import type { ToolDefinition, ToolExecuteReturn, ToolRunContext, ToolResult } from './index.ts' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { JsonValue } from '@deepseek-ai/dsh-session' +import type { ToolDefinition, ToolRunContext, ToolResult } from './index.ts' +import { assertSupportedJsonSchema, isJsonSchemaRecord, isPlainJsonArray, JsonSchemaError, validateJsonSchemaValue } from './json-schema.ts' +import type { JsonSchemaNode, JsonSchemaScalar, ObjectJsonSchema } from './json-schema.ts' import type { ToolCallView, ToolResultView } from './presentation.ts' -// --------------------------------------------------------------------------- -// SchemaSpec — the author-facing per-property type -// --------------------------------------------------------------------------- - -/** Valid JSON Schema primitive types for tool parameters. */ -export type SchemaType = 'string' | 'number' | 'boolean' | 'object' | 'array' - -/** One schema-spec property entry. */ -export interface SchemaProp { - type: SchemaType - /** Per-property required flag (NOT the JSON Schema top-level required array). */ - required?: true - /** Human-readable description, surfaced in the JSON Schema as well. */ +/** Annotation keywords shared by every author-facing schema node. */ +export interface ValueSchemaAnnotations { + /** Human-readable description projected into JSON Schema and generated types. */ description?: string - /** Enum of allowed values (strings only). */ - enum?: string[] - /** - * Model-visible JSON Schema default annotation. Validation does not apply it; - * dynamic tool mounts may supply it even though first-party definitions do not. - */ - default?: unknown - /** Nested properties for type: 'object'. */ - properties?: SchemaSpec - /** Items schema for type: 'array'. */ - items?: SchemaProp + /** Human-readable title projected into JSON Schema. */ + title?: string + /** Non-validating default annotation; it must be lossless JSON data. */ + default?: JsonValue + /** Non-validating examples annotation; it must be lossless JSON data. */ + examples?: JsonValue +} + +/** String value schema with type-correct literal constraints. */ +export interface StringValueSchemaSpec extends ValueSchemaAnnotations { + type: 'string' + enum?: readonly string[] + const?: string +} + +/** Finite JSON-number schema with type-correct literal constraints. */ +export interface NumberValueSchemaSpec extends ValueSchemaAnnotations { + type: 'number' + enum?: readonly number[] + const?: number +} + +/** Integer schema with type-correct literal constraints. */ +export interface IntegerValueSchemaSpec extends ValueSchemaAnnotations { + type: 'integer' + enum?: readonly number[] + const?: number +} + +/** Boolean value schema with type-correct literal constraints. */ +export interface BooleanValueSchemaSpec extends ValueSchemaAnnotations { + type: 'boolean' + enum?: readonly boolean[] + const?: boolean +} + +/** Null value schema with type-correct literal constraints. */ +export interface NullValueSchemaSpec extends ValueSchemaAnnotations { + type: 'null' + enum?: readonly null[] + const?: null +} + +/** Array value schema; omitted `items` accepts any lossless JSON item. */ +export interface ArrayValueSchemaSpec extends ValueSchemaAnnotations { + type: 'array' + items?: ValueSchemaSpec } /** - * The author-facing parameter schema: a shallow map of property name to - * {@link SchemaProp}. Required-ness is a per-property boolean (`required: - * true`), not a separate array. + * Explicit object value schema. Openness is mandatory so a nested or output + * object never acquires an accidental JSON Schema default. */ -export type SchemaSpec = Record +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 = { + [key: string]: ParameterPropertySpec + [key: symbol]: never +} + +/** 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] +/** String keys of one property map; runtime compilation rejects symbol keys. */ +type StringKeyOf = Extract -/** - * 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 +/** Keys of a property map marked `required: true`. */ +type RequiredKeys = { + [K in StringKeyOf]: S[K] extends { required: true } ? K : never +}[StringKeyOf] -/** - * 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 the declared value of one parameter property without key optionality. */ +type InferProperty = InferValueAt + +/** Infer an implicit property map into required and optional object keys. */ +type InferProperties = Simplify< + & { [K in RequiredKeys]: InferProperty } + & { [K in Exclude, RequiredKeys>]?: InferProperty } > -// --------------------------------------------------------------------------- -// Runtime conversion: SchemaSpec → JSON Schema -// --------------------------------------------------------------------------- +/** Infer an explicit object node, including its declared openness. */ +type InferObject = + S extends { properties: infer P } + ? 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 + +/** Add one schema-container level to bounded compile-time inference. */ +type NextInferenceDepth = [unknown, ...Depth] + +/** Infer one node without recursively checking it against the full author union. */ +type InferValueAt = + Depth['length'] extends 16 ? JsonValue : + S extends { type: 'string' } ? InferScalar : + S extends { type: 'number' | 'integer' } ? InferScalar : + S extends { type: 'boolean' } ? InferScalar : + S extends { type: 'null' } ? null : + S extends { type: 'array' } + ? S extends { items: infer I } ? InferValueAt>[] : JsonValue[] + : S extends { type: 'object'; additionalProperties: boolean } + ? InferObject> + : S extends { type: 'json' } ? JsonValue : + S extends { oneOf: readonly unknown[] } + ? InferValueAt> + : never /** - * Convert a single {@link SchemaProp} to its JSON Schema `properties` entry. - * The per-property `required` flag is collected; the caller builds the - * top-level `required` array. + * Infer the TypeScript value accepted by an author-facing value schema. Exact + * inference is bounded to 16 container levels, then falls back to `JsonValue`. */ -function propToJsonSchema(prop: SchemaProp): { schema: Record; 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 = InferValueAt - 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 - if (prop.type === 'array' && prop.items) { - const { schema: itemsSchema } = propToJsonSchema(prop.items) - result.items = itemsSchema - } - - return { schema: result, required } +/** Throw one author-schema violation through the shared schema error type. */ +function authorError(message: string): never { + throw new JsonSchemaError([message]) } -/** The return type of {@link schemaSpecToJsonSchema}. */ -export interface JsonSchemaObject { - type: 'object' - properties: Record +/** 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`) + } +} + +/** Compiled form of one implicit property map. */ +interface CompiledPropertyMap { + properties: Record required?: string[] } -/** - * Convert a {@link SchemaSpec} to standard JSON Schema (`type: 'object'`, - * `properties`, `required` array). - * - * This is a plain function — no schemastery or other framework dependency. - * @param spec - the author-facing per-property schema to convert. - * @returns the wire-format JSON Schema; the top-level `required` array is - * omitted entirely when no property is marked required. - */ -export function schemaSpecToJsonSchema(spec: SchemaSpec): JsonSchemaObject { - const properties: Record = {} - const required: string[] = [] - - for (const [key, prop] of Object.entries(spec)) { - const { schema, required: isRequired } = propToJsonSchema(prop) - properties[key] = schema - if (isRequired) required.push(key) - } - - const result: JsonSchemaObject = { - type: 'object', - properties, - } - if (required.length > 0) result.required = required - - return result +/** Mutable holder used only while an iterative compilation root is unresolved. */ +interface CompileRoot { + value?: T } -// --------------------------------------------------------------------------- -// Runtime validation: model-generated args ↔ SchemaSpec -// --------------------------------------------------------------------------- +/** Where one compiled value node is installed. */ +type NodeDestination = + | { kind: 'root'; holder: CompileRoot } + | { kind: 'property'; target: Record; key: string } + | { kind: 'item'; target: JsonSchemaNode } + | { kind: 'one-of'; target: JsonSchemaNode[]; index: number } + +/** Where one compiled property map is installed. */ +type PropertyMapDestination = + | { kind: 'root'; holder: CompileRoot } + | { kind: 'object'; target: JsonSchemaNode } + +/** Deferred work for stack-safe author-schema compilation. */ +type CompileTask = + | { kind: 'value'; input: unknown; path: string; allowRequired: boolean; destination: NodeDestination } + | { kind: 'property-map'; input: unknown; path: string; destination: PropertyMapDestination } + | { + kind: 'property' + property: unknown + path: string + key: string + properties: Record + required: string[] + } + | { + kind: 'property-map-tail' + compiled: CompiledPropertyMap + required: string[] + destination: PropertyMapDestination + } + | { kind: 'leave'; input: object } + +/** Install a compiled node without giving `__proto__` assignment semantics. */ +function assignCompiledNode(destination: NodeDestination, node: JsonSchemaNode): void { + switch (destination.kind) { + case 'root': + destination.holder.value = node + break + case 'property': + Object.defineProperty(destination.target, destination.key, { + value: node, + enumerable: true, + configurable: true, + writable: true, + }) + break + case 'item': + destination.target.items = node + break + case 'one-of': + destination.target[destination.index] = node + break + } +} + +/** Install a compiled property map at its root or containing object node. */ +function assignCompiledPropertyMap(destination: PropertyMapDestination, compiled: CompiledPropertyMap): void { + if (destination.kind === 'root') { + destination.holder.value = compiled + } else { + destination.target.properties = compiled.properties + } +} + +/** Execute an author-schema compilation task graph without recursive descent. */ +function runSchemaCompiler(initial: CompileTask): void { + const seen = new Set() + const tasks: CompileTask[] = [initial] + for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) { + if (task.kind === 'leave') { + seen.delete(task.input) + continue + } + if (task.kind === 'property-map-tail') { + if (task.required.length > 0) { + task.compiled.required = task.required + if (task.destination.kind === 'object') task.destination.target.required = task.required + } + continue + } + if (task.kind === 'property') { + if (!isJsonSchemaRecord(task.property)) authorError(`${task.path} must be a value schema object`) + if (Object.hasOwn(task.property, 'required') && task.property.required !== true) { + authorError(`${task.path}.required must be true when present`) + } + if (Object.hasOwn(task.property, 'required') && task.property.required === true) task.required.push(task.key) + tasks.push({ + kind: 'value', + input: task.property, + path: task.path, + allowRequired: true, + destination: { kind: 'property', target: task.properties, key: task.key }, + }) + continue + } + if (task.kind === 'property-map') { + if (!isJsonSchemaRecord(task.input)) authorError(`${task.path} must be an object of value schemas`) + if (seen.has(task.input)) authorError(`${task.path} is circular`) + seen.add(task.input) + const compiled: CompiledPropertyMap = { properties: {} } + const required: string[] = [] + assignCompiledPropertyMap(task.destination, compiled) + tasks.push({ kind: 'leave', input: task.input }) + tasks.push({ kind: 'property-map-tail', compiled, required, destination: task.destination }) + const entries = Object.entries(task.input) + for (let index = entries.length - 1; index >= 0; index--) { + const entry = entries[index] + /* v8 ignore next -- the loop is bounded by the captured entry count. */ + if (entry === undefined) continue + tasks.push({ + kind: 'property', + property: entry[1], + path: `${task.path}.${entry[0]}`, + key: entry[0], + properties: compiled.properties, + required, + }) + } + continue + } + + const { input, path } = task + if (!isJsonSchemaRecord(input)) authorError(`${path} must be a value schema object`) + if (seen.has(input)) authorError(`${path} is circular`) + seen.add(input) + const authorKeys = [...ANNOTATION_KEYS, ...(task.allowRequired ? ['required'] : [])] + const node: JsonSchemaNode = {} + assignCompiledNode(task.destination, node) + tasks.push({ kind: 'leave', input }) + + if (Object.hasOwn(input, 'oneOf')) { + assertAuthorKeys(input, path, [...authorKeys, 'oneOf', 'type']) + if (Object.hasOwn(input, 'type')) authorError(`${path} cannot declare both type and oneOf`) + if (!isPlainJsonArray(input.oneOf)) authorError(`${path}.oneOf must be an array of at least two value schemas`) + const branches: JsonSchemaNode[] = [] + node.oneOf = branches + copyAnnotations(input, node) + for (let index = input.oneOf.length - 1; index >= 0; index--) { + tasks.push({ + kind: 'value', + input: input.oneOf[index], + path: `${path}.oneOf[${index}]`, + allowRequired: false, + destination: { kind: 'one-of', target: branches, index }, + }) + } + continue + } + + const inputType = Object.hasOwn(input, 'type') ? input.type : undefined + switch (inputType) { + case 'json': + assertAuthorKeys(input, path, [...authorKeys, 'type']) + copyAnnotations(input, node) + break + case 'object': + assertAuthorKeys(input, path, [...authorKeys, 'type', 'properties', 'additionalProperties']) + if (!Object.hasOwn(input, 'additionalProperties') || typeof input.additionalProperties !== 'boolean') { + authorError(`${path}.additionalProperties must be explicitly true or false`) + } + node.type = 'object' + copyAnnotations(input, node) + node.additionalProperties = input.additionalProperties + if (Object.hasOwn(input, 'properties')) { + tasks.push({ + kind: 'property-map', + input: input.properties, + path: `${path}.properties`, + destination: { kind: 'object', target: node }, + }) + } + break + case 'array': + assertAuthorKeys(input, path, [...authorKeys, 'type', 'items']) + node.type = 'array' + copyAnnotations(input, node) + if (Object.hasOwn(input, 'items')) { + tasks.push({ + kind: 'value', + input: input.items, + path: `${path}.items`, + allowRequired: false, + destination: { kind: 'item', target: node }, + }) + } + break + case 'string': + case 'number': + case 'integer': + case 'boolean': + case 'null': + assertAuthorKeys(input, path, [...authorKeys, 'type', 'enum', 'const']) + node.type = inputType + copyAnnotations(input, node) + if (Object.hasOwn(input, 'enum')) { + if (!isPlainJsonArray(input.enum)) authorError(`${path}.enum must be a non-empty array of scalar values`) + node.enum = Array.from(input.enum, entry => entry as JsonSchemaScalar) + } + if (Object.hasOwn(input, 'const')) node.const = input.const as JsonSchemaScalar + break + default: + authorError(`${path}.type must be string/number/integer/boolean/null/array/object/json, or use oneOf`) + } + } +} + +/** Compile one implicit property map, collecting per-property requiredness. */ +function compilePropertyMap(input: unknown, path: string): CompiledPropertyMap { + const holder: CompileRoot = {} + runSchemaCompiler({ kind: 'property-map', input, path, destination: { kind: 'root', holder } }) + /* v8 ignore next -- the root task assigns before scheduling any descendants. */ + return holder.value ?? authorError(`${path} did not compile`) +} + +/** Compile one author node without applying any consumer root restriction. */ +function compileValueSchema(input: unknown, path: string): JsonSchemaNode { + const holder: CompileRoot = {} + runSchemaCompiler({ kind: 'value', input, path, allowRequired: false, destination: { kind: 'root', holder } }) + /* v8 ignore next -- the root task assigns before scheduling any descendants. */ + return holder.value ?? authorError(`${path} did not compile`) +} /** - * Thrown by a {@link defineTool} tool when the model-generated arguments don't - * match the declared {@link SchemaSpec}. Extends {@link HarnessError} - * (`code: 'INVALID_ARGS'`); the registry's execution pipeline catches it and - * returns an `isError` ToolExecutionResult carrying the structured error, so - * the model can self-correct and downstream plugins can route on the code. + * Compile one author-facing value schema to the enforced raw JSON Schema + * subset. The author-only `json` node becomes an annotation-only schema. + * @param spec - schema for any JSON-value root. + * @returns The asserted raw schema projection. */ +export function valueSchemaSpecToJsonSchema(spec: ValueSchemaSpec): JsonSchemaNode { + const schema = compileValueSchema(spec, 'schema') + assertSupportedJsonSchema(schema) + return schema +} + +/** + * Compile the implicit open parameter object into raw JSON Schema. + * @param spec - per-property parameter definitions. + * @returns An object-rooted raw schema with no implicit-root openness override. + */ +export function parameterSchemaSpecToJsonSchema(spec: ParameterSchemaSpec): ParameterJsonSchema { + const compiled = compilePropertyMap(spec, 'parameters') + const schema: ParameterJsonSchema = { + type: 'object', + properties: compiled.properties, + ...(compiled.required === undefined ? {} : { required: compiled.required }), + } + assertSupportedJsonSchema(schema) + return schema +} + +/** Invalid model-generated arguments for a typed tool. */ export class ToolArgsError extends HarnessError { - /** The individual violation messages, in declaration order. */ + /** Individual violations in schema-walk order. */ readonly violations: string[] constructor(violations: string[]) { @@ -177,155 +469,81 @@ export class ToolArgsError extends HarnessError { } } -/** Whether a value is a non-null, non-array object (a JSON Schema `object`). */ -function isPlainObject(value: unknown): value is Record { - 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. - */ + /** 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 /** - * 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 The canonical value declared by `output.schema`. */ - execute(args: InferArgs, exec: ToolRunContext): Promise + 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 + const userRender = options.output.render + // eslint-disable-next-line @typescript-eslint/unbound-method + const userPresentationMeta = options.output.presentationMeta + // eslint-disable-next-line @typescript-eslint/unbound-method const userPresentCall = options.presentCall // eslint-disable-next-line @typescript-eslint/unbound-method const userPresentResult = options.presentResult @@ -334,41 +552,46 @@ export function defineTool(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 outputSchema = valueSchemaSpecToJsonSchema(options.output.schema) + const validate = (args: unknown): string[] => validateJsonSchemaValue(parameters, args, '') const tool: ToolDefinition = { name: options.name, description: options.description, - parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record, + 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 { - // 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) + 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 }, } - // 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/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/src/ts-types.ts b/packages/core/tools/src/ts-types.ts index e5f67d0891..36d8f1dcc8 100644 --- a/packages/core/tools/src/ts-types.ts +++ b/packages/core/tools/src/ts-types.ts @@ -7,6 +7,13 @@ */ import type { ToolSchema } from '@deepseek-ai/dsh-llm' +import { assertSupportedJsonSchema } from './json-schema.ts' +import type { JsonSchemaNode, JsonSchemaScalar } from './json-schema.ts' +/** Internal Code Mode projection: the model-facing schema plus the canonical output schema. */ +export interface ToolSdkSchema extends ToolSchema { + /** Validated canonical value returned by the tool binding. */ + output: JsonSchemaNode +} /** Property names that are valid bare TS identifiers; anything else is quoted. */ const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/ @@ -30,48 +37,212 @@ function docLines(description: unknown, indent: number): string[] { return [`${pad(indent)}/** ${collapsed.replaceAll('*/', String.raw`*\/`)} */`] } +/** Render one scalar already validated by the unified schema boundary. */ +function renderScalar(value: JsonSchemaScalar): string { + return JSON.stringify(value) +} + +/** Render a validated scalar `const`/`enum`, falling back to the broad type. */ +function renderConstrainedScalar(node: Record, type: string): string { + const broad = type === 'integer' ? 'number' : type + if (Object.hasOwn(node, 'const')) return renderScalar(node.const as JsonSchemaScalar) + if (Object.hasOwn(node, 'enum')) { + return (node.enum as JsonSchemaScalar[]).map(renderScalar).join(' | ') + } + return broad +} + +/** A composable type document that can be flattened without recursive string concatenation. */ +interface TypeDocument { + readonly parts: readonly (string | TypeDocument)[] + readonly containsUnionOrIntersection: boolean +} + +/** Build one document from captured parts while retaining the legacy array-parenthesization test. */ +function typeDocumentFrom(parts: readonly (string | TypeDocument)[]): TypeDocument { + return { + parts, + containsUnionOrIntersection: parts.some(part => typeof part === 'string' + ? part.includes('|') || part.includes('&') + : part.containsUnionOrIntersection), + } +} + +/** Build a small document without an intermediate array at each call site. */ +function typeDocument(...parts: (string | TypeDocument)[]): TypeDocument { + return typeDocumentFrom(parts) +} + +/** Flatten a nested document with an explicit work stack. */ +function flattenTypeDocument(document: TypeDocument): string { + const chunks: string[] = [] + const tasks: (string | TypeDocument)[] = [document] + for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) { + if (typeof task === 'string') { + chunks.push(task) + continue + } + for (let index = task.parts.length - 1; index >= 0; index--) { + const part = task.parts[index] + /* v8 ignore next -- the loop is bounded by the captured part count. */ + if (part !== undefined) tasks.push(part) + } + } + return chunks.join('') +} + +/** One explicit call frame for stack-safe schema-to-TypeScript rendering. */ +interface SchemaRenderFrame { + readonly node: JsonSchemaNode + readonly indent: number + phase: 'start' | 'children' + kind?: 'oneOf' | 'array' | 'object' + children: { node: JsonSchemaNode; indent: number }[] + childIndex: number + childDocuments: TypeDocument[] + entries: [string, JsonSchemaNode][] +} + +/** Initialize one schema-render frame with empty aggregation state. */ +function schemaRenderFrame(node: JsonSchemaNode, indent: number): SchemaRenderFrame { + return { node, indent, phase: 'start', children: [], childIndex: 0, childDocuments: [], entries: [] } +} + +/** Render an already asserted schema to a composable document. */ +function renderSupportedSchema(schema: JsonSchemaNode, indent: number): TypeDocument { + const frames: SchemaRenderFrame[] = [schemaRenderFrame(schema, indent)] + let rootDocument: TypeDocument | undefined + const finish = (document: TypeDocument): void => { + frames.pop() + const parent = frames.at(-1) + if (parent === undefined) rootDocument = document + else parent.childDocuments.push(document) + } + + while (frames.length > 0) { + const frame = frames.at(-1) + /* v8 ignore next -- the loop condition guarantees a current frame. */ + if (frame === undefined) break + if (frame.phase === 'children') { + if (frame.childIndex < frame.children.length) { + const child = frame.children[frame.childIndex] + /* v8 ignore next -- childIndex is bounded by children.length. */ + if (child === undefined) throw new Error('missing schema render child') + frame.childIndex++ + frames.push(schemaRenderFrame(child.node, child.indent)) + continue + } + if (frame.kind === 'oneOf') { + const parts: (string | TypeDocument)[] = [] + for (let index = 0; index < frame.childDocuments.length; index++) { + if (index > 0) parts.push(' | ') + const child = frame.childDocuments[index] + /* v8 ignore next -- child documents correspond one-to-one with children. */ + if (child !== undefined) parts.push(child) + } + finish(typeDocumentFrom(parts)) + continue + } + if (frame.kind === 'array') { + const child = frame.childDocuments[0] + /* v8 ignore next -- array frames always schedule exactly one child. */ + if (child === undefined) throw new Error('missing array item type') + finish(child.containsUnionOrIntersection + ? typeDocument('(', child, ')[]') + : typeDocument(child, '[]')) + continue + } + + const required = new Set(frame.node.required) + const parts: (string | TypeDocument)[] = ['{'] + for (let index = 0; index < frame.entries.length; index++) { + const entry = frame.entries[index] + const child = frame.childDocuments[index] + /* v8 ignore next -- object entries and child documents have the same length. */ + if (entry === undefined || child === undefined) throw new Error('missing object property type') + const [name, prop] = entry + for (const line of docLines(prop.description, frame.indent + 1)) parts.push('\n', line) + parts.push('\n', `${pad(frame.indent + 1)}${renderKey(name)}${required.has(name) ? '' : '?'}: `, child, ';') + } + parts.push('\n', `${pad(frame.indent)}}`) + const declared = typeDocumentFrom(parts) + finish(frame.node.additionalProperties === false + ? declared + : typeDocument(declared, ' & Record')) + continue + } + + const node = frame.node + if (node.oneOf !== undefined) { + frame.kind = 'oneOf' + frame.children = Array.from(node.oneOf, child => ({ node: child, indent: frame.indent })) + frame.childIndex = 0 + frame.childDocuments = [] + frame.phase = 'children' + continue + } + if (node.type === undefined) { + finish(typeDocument('JsonValue')) + continue + } + switch (node.type) { + case 'string': + case 'number': + case 'integer': + case 'boolean': + case 'null': + finish(typeDocument(renderConstrainedScalar(node as Record, node.type))) + break + case 'array': + if (node.items === undefined) { + finish(typeDocument('JsonValue[]')) + } else { + frame.kind = 'array' + frame.children = [{ node: node.items, indent: frame.indent }] + frame.childIndex = 0 + frame.childDocuments = [] + frame.phase = 'children' + } + break + case 'object': { + const open = node.additionalProperties !== false + const entries = Object.entries(node.properties ?? {}) + if (entries.length === 0) { + finish(typeDocument(open ? 'Record' : 'Record')) + } else { + frame.kind = 'object' + frame.entries = entries + frame.children = entries.map(([, child]) => ({ node: child, indent: frame.indent + 1 })) + frame.childIndex = 0 + frame.childDocuments = [] + frame.phase = 'children' + } + break + } + /* v8 ignore next -- assertSupportedJsonSchema narrowed this closed type union. */ + default: + finish(typeDocument('unknown')) + } + } + + /* v8 ignore next -- every root frame produces one document. */ + return rootDocument ?? typeDocument('unknown') +} + /** - * Map one JSON-Schema node to a TypeScript type literal. Handles exactly the - * subset the `defineTool` DSL emits — `object` (`properties` + `required`), - * `string` (with `enum` → a literal union), `number`, `boolean`, `array` - * (`items`) — and returns `unknown` for anything else, without throwing. + * Map one enforced JSON-Schema node to a TypeScript type literal. Supports + * every unified schema construct and returns `unknown` for malformed or + * unsupported inputs without throwing. * @param schema - the JSON-Schema node (any shape; hostile inputs degrade). * @param indent - the indentation level for nested object members. * @returns the TS type text (multi-line for objects with properties). */ export function jsonSchemaToTs(schema: unknown, indent = 0): string { - if (typeof schema !== 'object' || schema === null) return 'unknown' - const node = schema as Record - switch (node.type) { - case 'string': { - if (Array.isArray(node.enum) && node.enum.length > 0 && node.enum.every(value => typeof value === 'string')) { - return node.enum.map(value => JSON.stringify(value)).join(' | ') - } - return 'string' - } - case 'number': return 'number' - case 'boolean': return 'boolean' - case 'array': { - const item = jsonSchemaToTs(node.items, indent) - // Parenthesize a union item type so `('a' | 'b')[]` parses as intended. - return item.includes('|') ? `(${item})[]` : `${item}[]` - } - case 'object': { - const properties = node.properties - if (typeof properties !== 'object' || properties === null) return 'Record' - 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') : []) - const lines: string[] = ['{'] - for (const [name, prop] of entries) { - const description = typeof prop === 'object' && prop !== null ? (prop as Record).description : undefined - lines.push(...docLines(description, indent + 1)) - lines.push(`${pad(indent + 1)}${renderKey(name)}${required.has(name) ? '' : '?'}: ${jsonSchemaToTs(prop, indent + 1)};`) - } - lines.push(`${pad(indent)}}`) - return lines.join('\n') - } - default: return 'unknown' + try { + assertSupportedJsonSchema(schema) + return flattenTypeDocument(renderSupportedSchema(schema, indent)) + } catch { + return 'unknown' } } @@ -80,8 +251,8 @@ const SDK_INSTRUCTIONS = `## Writing code for run_code Pass \`run_code\` the body of an async TypeScript function (erasable syntax only — no \`enum\` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program: -- Call tools as \`await tools.name(args)\` — quoted access for exotic names: \`tools["my-tool"](args)\`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable. -- A FAILED tool call rejects with an \`Error\` carrying the tool's error text — \`try/catch\` it to handle and continue. +- Call tools as \`await tools.name(args)\` — quoted access for exotic names: \`tools["my-tool"](args)\`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. +- A FAILED tool call rejects with \`ToolCallError\`, whose \`toolName\` identifies the failed tool and whose \`message\` is human-readable — \`try/catch\` it to handle and continue. - Calls execute sequentially, even under \`Promise.all\`. - Emit results with \`return\` and/or \`console.log(...)\`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need. @@ -96,15 +267,24 @@ The available tools:` * `run_code` itself). * @returns the complete section text. */ -export function renderToolsSdk(schemas: ToolSchema[]): string { +export function renderToolsSdk(schemas: ToolSdkSchema[]): string { const sorted = [...schemas].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0) - const members: string[] = [] + const argsMembers: string[] = [] + const outputMembers: string[] = [] for (const schema of sorted) { - members.push(...docLines(schema.description, 1)) - members.push(`${pad(1)}${renderKey(schema.name)}(args: ${jsonSchemaToTs(schema.parameters, 1)}): Promise;`) + argsMembers.push(...docLines(schema.description, 1)) + argsMembers.push(`${pad(1)}${renderKey(schema.name)}: ${jsonSchemaToTs(schema.parameters, 1)};`) + outputMembers.push(`${pad(1)}${renderKey(schema.name)}: ${jsonSchemaToTs(schema.output, 1)};`) } - const declaration = members.length > 0 - ? `declare const tools: {\n${members.join('\n')}\n}` - : 'declare const tools: {}' - return `${SDK_INSTRUCTIONS}\n\n\`\`\`ts\n${declaration}\n\`\`\`` + const argsMap = `interface ToolArgsMap {${argsMembers.length > 0 ? `\n${argsMembers.join('\n')}\n` : ''}}` + const outputMap = `interface ToolOutputMap {${outputMembers.length > 0 ? `\n${outputMembers.join('\n')}\n` : ''}}` + const declaration = [ + argsMap, + outputMap, + 'type ToolName = keyof ToolOutputMap', + ['declare class ToolCallError extends Error {', ' readonly name: "ToolCallError";', ' readonly toolName: ToolName;', '}'].join('\n'), + ['declare const tools: {', ' [K in ToolName]: (args: ToolArgsMap[K]) => Promise;', '}'].join('\n'), + ].join('\n\n') + const jsonValue = 'type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }' + return `${SDK_INSTRUCTIONS}\n\n\`\`\`ts\n${jsonValue}\n\n${declaration}\n\`\`\`` } diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index b8f47cca81..0681ffc571 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -6,11 +6,11 @@ import type { Scope } from '@deepseek-ai/dsh-scope' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' -import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, TOOL_ABORTED_BEFORE_DISPATCH, defineTool } from '@deepseek-ai/dsh-tools' -import type { Config, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, TOOL_ABORTED_BEFORE_DISPATCH, defineContentToolFixture, defineTool } from '@deepseek-ai/dsh-tools' +import type { Config, JsonSchemaNode, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import type { SessionEventMap } from '@deepseek-ai/dsh-session' +import type { JsonValue, SessionEventMap } from '@deepseek-ai/dsh-session' const testToolSignal = new AbortController().signal @@ -74,9 +74,13 @@ function registerEcho(ctx: Context, name = 'echo'): unknown[] { name, description: `Echo tool ${name}.`, parameters: { value: { type: 'string', required: true } }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, execute(args) { calls.push(args) - return Promise.resolve([{ type: 'text' as const, text: `${name}:${args.value}` }]) + return Promise.resolve(`${name}:${args.value}`) }, })) return calls @@ -122,8 +126,32 @@ describe('mode-aware wire contribution', () => { expect(assembly.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME]) const sdk = assembly.sections.find(section => section.name === 'tools:sdk') expect(sdk?.text).toContain('declare const tools: {') - expect(sdk?.text).toContain('echo(args:') - expect(sdk?.text).not.toContain('run_code(args:') + expect(sdk?.text).toContain('echo: {') + expect(sdk?.text).not.toContain('run_code:') + }) + + it('projects deeply nested output schemas into the Code Mode SDK without structured-clone recursion', async () => { + const { ctx, systemPrompt } = await setup({ mode: 'code' }) + let output: JsonSchemaNode = { type: 'string' } + for (let depth = 0; depth < 5_000; depth++) { + output = { oneOf: [output, { type: 'null' }] } + } + ctx.tools.register({ + name: 'deep_output', + description: 'Return a deeply nested output union.', + parameters: { type: 'object', properties: {} }, + output: { + schema: output, + render: (_args, value) => [{ type: 'text', text: typeof value === 'string' ? value : 'null' }], + }, + execute() { return Promise.resolve('ok') }, + }) + + const assembly = await systemPrompt.assemble() + const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text + + expect(sdk).toContain('deep_output: Record;') + expect(sdk).toContain('deep_output: string | null') }) it.each(['code', 'both'] as const)('treats expert assembly output as authoritative in mode %s', async (mode) => { @@ -175,8 +203,8 @@ describe('mode-aware wire contribution', () => { ? [RUN_CODE_NAME] : ['echo', RUN_CODE_NAME]) const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text - expect(sdk).toContain('echo(args:') - expect(sdk).not.toContain('hidden(args:') + expect(sdk).toContain('echo: {') + expect(sdk).not.toContain('hidden:') runtime.behavior = request => Promise.resolve({ logs: [], @@ -205,8 +233,8 @@ describe('mode-aware wire contribution', () => { ? [RUN_CODE_NAME] : ['kept', RUN_CODE_NAME]) const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text - expect(sdk).not.toContain('denied(args:') - expect(sdk).toContain('kept(args:') + expect(sdk).not.toContain('denied:') + expect(sdk).toContain('kept: {') runtime.behavior = request => Promise.resolve({ logs: [], @@ -220,7 +248,7 @@ describe('mode-aware wire contribution', () => { it.each(['code', 'both'] as const)('reserves run_code against scoped shadows and explicit restrictions in mode %s', async (mode) => { const { ctx, systemPrompt } = await setup({ mode }) const { scope, agent } = await mintAgentScope(ctx) - const impostor = defineTool({ + const impostor = defineContentToolFixture({ name: RUN_CODE_NAME, description: 'Scoped impostor.', parameters: {}, @@ -232,7 +260,7 @@ describe('mode-aware wire contribution', () => { expect(() => scope.ctx.tools.restrict({ allow: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/) expect(() => scope.ctx.tools.restrict({ deny: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/) scope.ctx.systemPrompt.section({ name: 'scoped-note', order: 149, text: 'safe note' }) - scope.ctx.tools.register(defineTool({ + scope.ctx.tools.register(defineContentToolFixture({ name: 'scoped_safe', description: 'Safe scoped tool.', parameters: {}, @@ -244,7 +272,7 @@ describe('mode-aware wire contribution', () => { expect(transports).toHaveLength(1) expect(transports[0]?.description).toContain('Execute a TypeScript program') expect(assembly.sections.find(section => section.name === 'scoped-note')?.text).toBe('safe note') - expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('scoped_safe(args:') + expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('scoped_safe:') expect(ctx.tools.get(RUN_CODE_NAME, agent)).toBe(ctx.tools.get(RUN_CODE_NAME)) const result = await runCode(ctx, 'return 1', { agent }) expect(result.content).toEqual([{ type: 'text', text: '(run_code completed with no output)' }]) @@ -268,6 +296,10 @@ describe('mode-aware wire contribution', () => { const { ctx, runtime } = await setup({ mode: 'both' }) registerEcho(ctx) runtime.behavior = (request) => { + expect(request.bindings[0]!.errorClass).toEqual({ + name: 'ToolCallError', + memberNameProperty: 'toolName', + }) const functions = request.bindings[0]!.functions return Promise.resolve({ logs: [], @@ -331,10 +363,13 @@ describe('the run_code dispatch bridge', () => { const tools = request.bindings[0]!.functions const first = await tools.echo!({ value: 'one' }) const second = await tools.echo!({ value: 'two' }) - return { logs: [`saw ${String(first)}`], value: second } + if (typeof first !== 'string' || typeof second !== 'string') throw new Error('echo returned a non-string') + return { logs: [`saw ${first}`], value: second } } const result = await runCode(ctx, 'const …: string = …', { agent }) expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected run_code success') + expect(result.value).toEqual({ logs: ['saw echo:one'], result: 'echo:two' }) expect(result.content).toEqual([{ type: 'text', text: 'saw echo:one\necho:two' }]) expect(calls).toEqual([{ value: 'one' }, { value: 'two' }]) const dispatches = events.filter(event => event.type === 'tool/code-dispatch') @@ -342,7 +377,7 @@ describe('the run_code dispatch bridge', () => { { parentCallId: 'call-1', subCallId: 'call-1:code:1', name: 'echo', arguments: { value: 'one' }, isError: false, resultSummary: 'echo:one' }, { parentCallId: 'call-1', subCallId: 'call-1:code:2', name: 'echo', arguments: { value: 'two' }, isError: false, resultSummary: 'echo:two' }, ]) - expect(result.meta).toEqual({ logs: ['saw echo:one'] }) + expect(result.meta).toBeUndefined() }) it('exposes only an opaque parent token to nested result observers', async () => { @@ -380,6 +415,10 @@ describe('the run_code dispatch bridge', () => { name: 'probe', description: 'Records execution overlap.', parameters: { id: { type: 'string', required: true } }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, async execute(args) { active++ expect(active, 'probe executions overlapped').toBe(1) @@ -387,12 +426,13 @@ describe('the run_code dispatch bridge', () => { await new Promise(resolve => setTimeout(resolve, 20)) intervals.push(['exit', args.id]) active-- - return [{ type: 'text' as const, text: args.id }] + return args.id }, })) runtime.behavior = async (request) => { const tools = request.bindings[0]!.functions const values = await Promise.all([tools.probe!({ id: 'a' }), tools.probe!({ id: 'b' }), tools.probe!({ id: 'c' })]) + if (!values.every(value => typeof value === 'string')) throw new Error('probe returned a non-string') return { logs: [], value: values.join(',') } } const result = await runCode(ctx, 'program') @@ -407,7 +447,7 @@ describe('the run_code dispatch bridge', () => { it('rejects the program-side call when the tool errors, with the tool error text', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'fail', description: 'Always fails.', parameters: {}, @@ -422,7 +462,7 @@ describe('the run_code dispatch bridge', () => { } } const result = await runCode(ctx, 'program') - expect(result.content[0]).toEqual({ type: 'text', text: 'caught: Error: deliberate failure' }) + expect(result.content[0]).toEqual({ type: 'text', text: 'caught: deliberate failure' }) }) it('a tools/pre-execute deny reaches the program as a binding rejection', async () => { @@ -445,7 +485,7 @@ describe('the run_code dispatch bridge', () => { expect((result.content[0] as { text: string }).text).toContain('not on my watch') }) - it('rejects a binding argument that does not survive JSON normalization, dispatching nothing', async () => { + it('rejects a binding argument that is not lossless JSON, dispatching nothing', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) const calls = registerEcho(ctx) const { agent, events } = fakeAgent() @@ -458,25 +498,24 @@ describe('the run_code dispatch bridge', () => { } } const result = await runCode(ctx, 'program', { agent }) - expect((result.content[0] as { text: string }).text).toContain('JSON-serializable') + expect((result.content[0] as { text: string }).text).toContain('lossless JSON') expect(calls).toEqual([]) expect(events.filter(event => event.type === 'tool/code-dispatch')).toEqual([]) }) - it('dispatches the JSON-normalized value: what the tool sees is what the event logs', async () => { + it('dispatches and logs independent snapshots of the same lossless JSON value', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) const calls = registerEcho(ctx) const { agent, events } = fakeAgent() runtime.behavior = async (request) => { - // A Date survives structured clone but is not JSON; the bridge - // normalizes it to its JSON form (an ISO string) BEFORE dispatch. - await request.bindings[0]!.functions.echo!({ value: 'x', when: new Date(0) }).catch(() => undefined) + const args = Object.assign(Object.create(null) as Record, { value: 'x', nested: ['same'] }) + await request.bindings[0]!.functions.echo!(args) return { logs: [] } } await runCode(ctx, 'program', { agent }) - expect(calls).toEqual([{ value: 'x', when: '1970-01-01T00:00:00.000Z' }]) + expect(calls).toEqual([{ value: 'x', nested: ['same'] }]) const dispatch = events.find(event => event.type === 'tool/code-dispatch')?.data as SessionEventMap['tool/code-dispatch'] - expect(dispatch.arguments).toEqual({ value: 'x', when: '1970-01-01T00:00:00.000Z' }) + expect(dispatch.arguments).toEqual({ value: 'x', nested: ['same'] }) }) it('defers sub-call additionalContexts onto the outer run_code result', async () => { @@ -551,7 +590,7 @@ describe('the run_code dispatch bridge', () => { }) const result = await runCode(ctx, 'program') expect(result.isError).toBe(true) - expect(result.error).toEqual({ name: 'CodeRunFailedError', code: 'CODE_RUN_FAILED' }) + expect(result.error).toMatchObject({ info: { name: 'CodeRunFailedError', code: 'CODE_RUN_FAILED' } }) const text = (result.content[0] as { text: string }).text expect(text).toContain('code run failed (timeout)') expect(text).toContain('compute budget exhausted') @@ -568,7 +607,7 @@ describe('the run_code dispatch bridge', () => { const { ctx, runtime } = await setup({ mode: 'code' }) const seen: string[] = [] let sawAbort = false - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'slow', description: 'Slow tool observing its signal.', parameters: { id: { type: 'string', required: true } }, @@ -604,7 +643,7 @@ describe('the run_code dispatch bridge', () => { let sawAbort = false let started!: () => void const inFlight = new Promise((resolve) => { started = resolve }) - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'slow', description: 'Slow tool observing its signal.', parameters: { id: { type: 'string', required: true } }, @@ -654,7 +693,7 @@ describe('the run_code dispatch bridge', () => { expect((result.content[0] as { text: string }).text).toContain('requires a code runtime') }) - it('presents the PROGRAM as the execute-card title on both call and result (the one slot execute cards always show)', async () => { + it('presents the program as the execute-card title', async () => { const { ctx } = await setup({ mode: 'code' }) const tool = ctx.tools.get(RUN_CODE_NAME)! // The program IS the title, mirroring how command tools title their cards @@ -667,24 +706,59 @@ describe('the run_code dispatch bridge', () => { kind: 'execute', rawInput: 'return 1', }) - const view = tool.presentResult?.({ code: 'return 1' }, { - content: [{ type: 'text', text: 'model-facing' }], - isError: false, - meta: { logs: ['printed'] }, + }) + + it.each([ + ['logs only', { logs: ['printed'] }, 'printed'], + ['result only', { logs: [], value: 'returned' }, 'returned'], + ['logs plus result', { logs: ['printed'], value: 'returned' }, 'printed\nreturned'], + ['no output', { logs: [] }, '(run_code completed with no output)'], + ] as [string, CodeRunResult, string][])('keeps %s in durable content without a result presenter', async (_name, output, text) => { + const { ctx, runtime } = await setup({ mode: 'code' }) + runtime.behavior = () => Promise.resolve(output) + + const result = await runCode(ctx, 'return 1') + const tool = ctx.tools.get(RUN_CODE_NAME)! + + expect(result.content).toEqual([{ type: 'text', text }]) + // Surfaces keep the pending program title and render this durable content + // through their generic fallback. Omitting a result view also prevents the + // host frame from carrying the same raw content a second time. + expect('presentResult' in tool).toBe(false) + }) + + it('keeps a post-policy spill preview in durable content without a result presenter', async () => { + const { ctx, runtime } = await setup({ mode: 'code' }) + const preview = 'HEAD\n\n(Omitted 100 bytes. Full formatted result stored at: /tmp/run-code.txt.)\n\nTAIL' + runtime.behavior = () => Promise.resolve({ logs: ['printed'], value: 'returned' }) + ctx.on('tools/post-execute', (exec, _result, next): Promise => { + if (exec.name !== RUN_CODE_NAME) return next() + return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: preview }] }) }) - // The result omits the title — an update replaces only provided fields, - // so the pending card's program title persists through completion. - expect(view).toEqual({ - card: 'generic', - content: [{ type: 'text', text: 'printed' }], + + const result = await runCode(ctx, 'return 1') + const tool = ctx.tools.get(RUN_CODE_NAME)! + + expect(result.content).toEqual([{ type: 'text', text: preview }]) + expect('presentResult' in tool).toBe(false) + }) + + it('keeps canonical failure content durable without a result presenter', async () => { + const { ctx, runtime } = await setup({ mode: 'code' }) + runtime.behavior = () => Promise.resolve({ + logs: ['captured before failure'], + error: { kind: 'output-limit', message: 'outer output exceeded 8 bytes' }, }) - // No captured output → no content either; everything pending persists. - expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { logs: [] } })) - .toEqual({ card: 'generic' }) - // Replay with an unrecognizable meta falls back to the generic rendering. - expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { logs: [{ text: 'legacy' }], dispatches: 1 } })).toBeUndefined() - expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { other: true } })).toBeUndefined() - expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false })).toBeUndefined() + + const result = await runCode(ctx, 'return 1') + const tool = ctx.tools.get(RUN_CODE_NAME)! + + expect(result.isError).toBe(true) + expect(result.content).toEqual([{ + type: 'text', + text: 'Error: code run failed (output-limit): outer output exceeded 8 bytes\nCaptured output:\ncaptured before failure', + }]) + expect('presentResult' in tool).toBe(false) }) it('renders non-text sub-result blocks as placeholders and truncates long event summaries', async () => { @@ -695,11 +769,15 @@ describe('the run_code dispatch bridge', () => { name: 'mixed', description: 'Returns mixed content.', parameters: {}, + output: { + schema: { type: 'string' }, + render: () => [ + { type: 'text', text: long }, + { type: 'reasoning', text: 'hidden' }, + ], + }, execute() { - return Promise.resolve([ - { type: 'text' as const, text: long }, - { type: 'reasoning' as const, text: 'hidden' }, - ]) + return Promise.resolve('mixed-value') }, })) runtime.behavior = async (request) => { @@ -708,7 +786,7 @@ describe('the run_code dispatch bridge', () => { } const result = await runCode(ctx, 'program', { agent }) expect(result.isError).toBe(false) - expect((result.content[0] as { text: string }).text).toBe(`${long}\n[reasoning content]`) + expect((result.content[0] as { text: string }).text).toBe('mixed-value') const dispatch = events.find(event => event.type === 'tool/code-dispatch')?.data as SessionEventMap['tool/code-dispatch'] expect(dispatch.resultSummary.length).toBe(201) expect(dispatch.resultSummary.endsWith('…')).toBe(true) @@ -720,9 +798,13 @@ describe('the run_code dispatch bridge', () => { name: 'workspace_path', description: 'Return a path beneath the session workspace.', parameters: {}, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, execute(_args, exec) { const cwd = exec.agent?.session.header.cwd ?? '' - return Promise.resolve([{ type: 'text' as const, text: `${cwd}/nested/task.txt\n${'x'.repeat(240)}` }]) + return Promise.resolve(`${cwd}/nested/task.txt\n${'x'.repeat(240)}`) }, })) runtime.behavior = async request => ({ @@ -760,7 +842,7 @@ describe('the run_code dispatch bridge', () => { expect((root.events[0]!.data as SessionEventMap['tool/code-dispatch']).resultSummary).toBe('echo:/workspace/value') }) - it('rejects undefined, JSON-throwing, and JSON-unrepresentable binding arguments BEFORE dispatch', async () => { + it('rejects undefined, getter-throwing, exotic, and unrepresentable binding arguments before dispatch', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) const calls = registerEcho(ctx) const { agent, events } = fakeAgent() @@ -773,8 +855,9 @@ describe('the run_code dispatch bridge', () => { // Root undefined must reject up front: the event log rejects it as // data, and nothing may execute unlogged. await catchMessage(echo(undefined)), - // A toJSON that throws a NON-Error propagates out of JSON.stringify. - await catchMessage(echo({ toJSON() { throw 'raw-throw' } })), + await catchMessage(echo(Object.defineProperty({}, 'bad', { enumerable: true, get() { throw 'raw-throw' } }))), + await catchMessage(echo(Object.defineProperty({}, 'bad', { enumerable: true, get() { throw new Error('error-throw') } }))), + await catchMessage(echo(new Date(0))), // A bare function is a value JSON cannot represent at all. await catchMessage(echo(() => 1)), ].join(' | '), @@ -783,18 +866,70 @@ describe('the run_code dispatch bridge', () => { const result = await runCode(ctx, 'program', { agent }) const text = (result.content[0] as { text: string }).text expect(text).toContain('call the tool with an arguments object') - expect(text).toContain('JSON-serializable: raw-throw') - expect(text).toContain('a value JSON cannot represent') - // None of the three dispatched, none logged. + expect(text).toContain('lossless JSON: raw-throw') + expect(text).toContain('lossless JSON: error-throw') + expect(text.match(/tool arguments must be lossless JSON/g)).toHaveLength(5) + // None dispatched or logged. expect(calls).toEqual([]) expect(events.filter(event => event.type === 'tool/code-dispatch')).toEqual([]) }) + it('dispatches and durably logs binding arguments deeper than the structured-clone call stack', async () => { + const { ctx, runtime } = await setup({ mode: 'code' }) + const depth = 5_000 + let observedDepth = 0 + let observedLeaf: JsonValue | undefined + ctx.tools.register(defineTool({ + name: 'deep_args', + description: 'Measure a deeply nested JSON argument.', + parameters: { nested: { type: 'json', required: true } }, + output: { + schema: { type: 'integer' }, + render: (_args, value) => [{ type: 'text', text: String(value) }], + }, + execute(args) { + let cursor = args.nested + while (Array.isArray(cursor)) { + if (cursor.length !== 1) throw new Error('expected one item per nesting layer') + observedDepth++ + cursor = cursor[0]! + } + observedLeaf = cursor + return Promise.resolve(observedDepth) + }, + })) + const session = new Session(SessionId('deep-code-arguments')) + const agent = { session } as Agent + runtime.behavior = async (request) => { + let nested: JsonValue = 'leaf' + for (let index = 0; index < depth; index++) nested = [nested] + const value = await request.bindings[0]!.functions.deep_args!({ nested }) + return { logs: [], value } + } + + const result = await runCode(ctx, 'return tools.deep_args(...)', { agent }) + + expect(result.isError).toBe(false) + expect(result.isError ? undefined : result.value).toEqual({ logs: [], result: depth }) + expect({ observedDepth, observedLeaf }).toEqual({ observedDepth: depth, observedLeaf: 'leaf' }) + const dispatch = session.events.find(event => event.type === 'tool/code-dispatch') + if (dispatch === undefined) throw new Error('expected a durable tool/code-dispatch event') + const logged = dispatch.data.arguments as { nested: JsonValue } + let loggedDepth = 0 + let loggedCursor = logged.nested + while (Array.isArray(loggedCursor)) { + if (loggedCursor.length !== 1) throw new Error('expected one logged item per nesting layer') + loggedDepth++ + loggedCursor = loggedCursor[0]! + } + expect({ loggedDepth, loggedCursor }).toEqual({ loggedDepth: depth, loggedCursor: 'leaf' }) + }) + it('gives the tool and durable log the same immutable argument value', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) const { agent, events } = fakeAgent() let mutationSucceeded: boolean | undefined - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'mutator', description: 'Attempts to mutate its args object.', parameters: { list: { type: 'array', required: true } }, @@ -820,7 +955,11 @@ describe('the run_code dispatch bridge', () => { name: '__proto__', description: 'A prototype-colliding tool name.', parameters: {}, - execute() { return Promise.resolve([{ type: 'text' as const, text: 'proto-tool-ok' }]) }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, + execute() { return Promise.resolve('proto-tool-ok') }, })) runtime.behavior = async (request) => { const functions = request.bindings[0]!.functions @@ -833,11 +972,48 @@ describe('the run_code dispatch bridge', () => { expect(result.content[0]).toEqual({ type: 'text', text: 'proto-tool-ok' }) }) - it('renders a non-string completion value inspect-style', async () => { + it('renders every non-string JSON root as pretty JSON while preserving strings raw', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) - runtime.behavior = () => Promise.resolve({ logs: [], value: { n: 42 } }) - const result = await runCode(ctx, 'program') - expect((result.content[0] as { text: string }).text).toBe('{ n: 42 }') + runtime.behavior = () => Promise.resolve({ logs: [], value: { n: 42, ok: true } }) + expect((await runCode(ctx, 'object')).content[0]).toEqual({ type: 'text', text: '{\n "n": 42,\n "ok": true\n}' }) + runtime.behavior = () => Promise.resolve({ logs: [], value: {} }) + expect((await runCode(ctx, 'empty object')).content[0]).toEqual({ type: 'text', text: '{}' }) + const nested = { outer: [{ inner: true }] } + runtime.behavior = () => Promise.resolve({ logs: [], value: nested }) + expect((await runCode(ctx, 'nested')).content[0]).toEqual({ type: 'text', text: JSON.stringify(nested, null, 2) }) + runtime.behavior = () => Promise.resolve({ logs: [], value: ['x', 2] }) + expect((await runCode(ctx, 'array')).content[0]).toEqual({ type: 'text', text: '[\n "x",\n 2\n]' }) + runtime.behavior = () => Promise.resolve({ logs: [], value: [] }) + expect((await runCode(ctx, 'empty array')).content[0]).toEqual({ type: 'text', text: '[]' }) + runtime.behavior = () => Promise.resolve({ logs: [], value: null }) + expect((await runCode(ctx, 'null')).content[0]).toEqual({ type: 'text', text: 'null' }) + runtime.behavior = () => Promise.resolve({ logs: [], value: 'raw' }) + expect((await runCode(ctx, 'string')).content[0]).toEqual({ type: 'text', text: 'raw' }) + runtime.behavior = () => Promise.resolve({ logs: [] }) + const absent = await runCode(ctx, 'undefined') + expect(absent.content[0]).toEqual({ type: 'text', text: '(run_code completed with no output)' }) + expect(absent.isError ? undefined : absent.value).toEqual({ logs: [] }) + }) + + it('renders deeply nested JSON without recursive traversal or quadratic indentation', async () => { + const { ctx, runtime } = await setup({ mode: 'code' }) + let value: JsonValue = { + emptyArray: [], + emptyObject: {}, + pair: ['leaf', 2], + record: { first: true, second: null }, + } + for (let depth = 0; depth < 5_000; depth++) value = [value] + runtime.behavior = () => Promise.resolve({ logs: [], value }) + + const result = await runCode(ctx, 'deep result') + + expect(result.isError).toBe(false) + const text = (result.content[0] as { type: 'text'; text: string }).text + expect(text.startsWith('[\n [\n [')).toBe(true) + expect(text).toContain('"leaf"') + expect(text.endsWith(']')).toBe(true) + expect(text.length).toBeLessThan(11_000) }) it('short-circuits a pre-aborted outer signal before the code runtime', async () => { @@ -855,7 +1031,10 @@ describe('the run_code dispatch bridge', () => { expect(result).toEqual({ content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }], isError: true, - error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + error: { + message: 'tool call aborted before dispatch', + info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + }, }) expect(runtime.lastRequest).toBeUndefined() expect(calls).toEqual([]) @@ -873,7 +1052,10 @@ describe('the run_code dispatch bridge', () => { } const result = await runCode(ctx, 'program', { signal: controller.signal }) expect(result.isError).toBe(true) - expect(result.error).toEqual({ name: 'AbortError', code: 'ABORTED' }) + expect(result.error).toEqual({ + message: 'tool call aborted', + info: { name: 'AbortError', code: 'ABORTED' }, + }) expect((result.content[0] as { text: string }).text).toBe('Error: tool call aborted') expect(calls).toEqual([]) }) diff --git a/packages/core/tools/tests/execution-mode.spec.ts b/packages/core/tools/tests/execution-mode.spec.ts index 054eed65ed..54c4687c60 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, @@ -27,7 +27,7 @@ function exec(name: string, args: unknown): ToolExecutionInput { describe('ToolRegistry.executionMode', () => { it('returns parallel only for an explicit true classifier', async () => { const ctx = await setup() - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'safe', description: 'parallel-safe', parameters: {}, @@ -39,7 +39,7 @@ describe('ToolRegistry.executionMode', () => { it('defaults to exclusive for a tool with no isConcurrencySafe declaration', async () => { const ctx = await setup() - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'plain', description: 'no declaration', parameters: {}, @@ -55,7 +55,7 @@ describe('ToolRegistry.executionMode', () => { it('returns exclusive when the classifier returns false for these args', async () => { const ctx = await setup() - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'rw', description: 'read or write', parameters: { mode: { type: 'string', required: true } }, @@ -66,9 +66,9 @@ describe('ToolRegistry.executionMode', () => { expect(ctx.tools.executionMode(exec('rw', { mode: 'write' }))).toEqual({ kind: 'exclusive' }) }) - it('classifies invalid defineTool arguments as exclusive without throwing', async () => { + it('classifies invalid defineContentToolFixture arguments as exclusive without throwing', async () => { const ctx = await setup() - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'needs-mode', description: 'requires mode', parameters: { mode: { type: 'string', required: true } }, @@ -84,8 +84,9 @@ describe('ToolRegistry.executionMode', () => { name: 'thrower', description: 'classifier throws', parameters: { type: 'object', properties: {} }, + output: { schema: { type: 'null' }, render: () => [] }, isConcurrencySafe() { throw new Error('boom') }, - async execute() { return [] }, + async execute() { return null }, } ctx.tools.register(raw) expect(ctx.tools.executionMode(exec('thrower', {}))).toEqual({ kind: 'exclusive' }) @@ -97,8 +98,9 @@ describe('ToolRegistry.executionMode', () => { name: 'truthy', description: 'classifier returns a truthy string', parameters: { type: 'object', properties: {} }, + output: { schema: { type: 'null' }, render: () => [] }, isConcurrencySafe() { return 'yes' }, - async execute() { return [] }, + async execute() { return null }, } as unknown as ToolDefinition ctx.tools.register(raw) expect(ctx.tools.executionMode(exec('truthy', {}))).toEqual({ kind: 'exclusive' }) @@ -111,8 +113,9 @@ describe('ToolRegistry.executionMode', () => { name: 'raw-safe', description: 'raw', parameters: { type: 'object', properties: {} }, + output: { schema: { type: 'null' }, render: () => [] }, isConcurrencySafe(args) { seen = args; return true }, - async execute() { return [] }, + async execute() { return null }, }) expect(ctx.tools.executionMode(exec('raw-safe', { anything: 1 }))).toEqual({ kind: 'parallel' }) expect(seen).toEqual({ anything: 1 }) @@ -120,7 +123,7 @@ describe('ToolRegistry.executionMode', () => { it('isConcurrencySafe never reaches the model-facing schemas() projection', async () => { const ctx = await setup() - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name: 'safe', description: 'parallel-safe', parameters: { x: { type: 'string', required: true } }, diff --git a/packages/core/tools/tests/execution-signal-types.spec.ts b/packages/core/tools/tests/execution-signal-types.spec.ts index e0e030543f..dd878d648e 100644 --- a/packages/core/tools/tests/execution-signal-types.spec.ts +++ b/packages/core/tools/tests/execution-signal-types.spec.ts @@ -80,11 +80,15 @@ const inferredTool = defineTool({ name: 'signal-inference', description: 'Pins contextual signal inference.', parameters: {}, + output: { + schema: { type: 'null' }, + render: () => [], + }, async execute(_args, exec) { expectTypeOf(exec.signal).toEqualTypeOf() // @ts-expect-error -- defineTool contextually exposes a readonly signal. exec.signal = new AbortController().signal - return [] + return null }, }) void inferredTool diff --git a/packages/core/tools/tests/invariant.spec.ts b/packages/core/tools/tests/invariant.spec.ts index ef795cce73..3752dc7321 100644 --- a/packages/core/tools/tests/invariant.spec.ts +++ b/packages/core/tools/tests/invariant.spec.ts @@ -27,6 +27,7 @@ const execution = (overrides: Partial = {}): ToolExecution => ({ const outcome = (): ToolExecutionResult => Object.freeze({ content: Object.freeze([{ type: 'text' as const, text: 'ok' }]) as never, isError: false, + value: null, }) function emitResult(ctx: Context, exec: ToolExecution, result: ToolExecutionResult): void { @@ -78,7 +79,7 @@ describe('tool-pipeline invariants', () => { expect(() => { emitResult(ctx, execution(), outcome()) }).toThrow(/execution must be frozen/) const exec = Object.freeze(execution()) - expect(() => { emitResult(ctx, exec, { content: [], isError: false }) }) + expect(() => { emitResult(ctx, exec, { content: [], isError: false, value: null }) }) .toThrow(/outcome and content must be frozen/) const anonymous = Object.freeze(execution({ name: '' })) diff --git a/packages/core/tools/tests/json-schema.spec.ts b/packages/core/tools/tests/json-schema.spec.ts index 6fa895288e..fc29965c3b 100644 --- a/packages/core/tools/tests/json-schema.spec.ts +++ b/packages/core/tools/tests/json-schema.spec.ts @@ -1,304 +1,455 @@ +import { runInNewContext } from 'node:vm' import { describe, expect, it } from 'vitest' import { - assertSupportedOutputSchema, - OutputSchemaError, - validateStructuredValue, - type StructuredOutputSchema, -} from '../src/json-schema.ts' + assertObjectJsonSchema, + assertSupportedJsonSchema, + JsonSchemaError, + validateJsonSchemaValue, + type JsonSchemaNode, + type ObjectJsonSchema, +} from '../src/index.ts' -/** Assert-and-narrow helper: the asserted schema, typed. */ -function asserted(schema: unknown): StructuredOutputSchema { - assertSupportedOutputSchema(schema) +function asserted(schema: unknown): JsonSchemaNode { + assertSupportedJsonSchema(schema) return schema } -/** The violations OutputSchemaError carries for a bad schema (throws if it passes). */ -function violationsOf(schema: unknown): string[] { - try { - assertSupportedOutputSchema(schema) - } catch (error: unknown) { - if (error instanceof OutputSchemaError) return error.violations - throw error - } - throw new Error('expected the schema to be rejected') +function assertedObject(schema: unknown): ObjectJsonSchema { + assertObjectJsonSchema(schema) + return schema } -describe('assertSupportedOutputSchema', () => { - it('accepts a representative subset schema (all supported keywords)', () => { - const schema = asserted({ - type: 'object', - description: 'a finding', - title: 'Finding', - properties: { - file: { type: 'string', description: 'path' }, - line: { type: 'integer' }, - severity: { type: 'string', enum: ['low', 'high'] }, - kind: { type: 'string', const: 'bug' }, - score: { type: 'number' }, - confirmed: { type: 'boolean' }, - parent: { type: 'null' }, - tags: { type: 'array', items: { type: 'string' } }, - nested: { - type: 'object', - properties: { x: { type: 'number', default: 3, examples: [1, 2] } }, - additionalProperties: false, +function violationsOf(schema: unknown, objectRoot = false): string[] { + try { + if (objectRoot) assertObjectJsonSchema(schema) + else assertSupportedJsonSchema(schema) + } catch (error: unknown) { + if (error instanceof JsonSchemaError) return error.violations + throw error + } + throw new Error('expected schema rejection') +} + +function recordWithForgedIntrinsicPrototype( + own: Record, + inherited: Record = {}, + revoked = false, +): Record { + const prototype = Object.assign(Object.create(null) as Record, inherited) + const ForgedObject = function ForgedObject(): void {} + Object.defineProperty(ForgedObject, 'name', { value: 'Object' }) + ForgedObject.prototype = prototype + const constructor = revoked ? Proxy.revocable(ForgedObject, {}) : undefined + if (constructor !== undefined) constructor.revoke() + Object.defineProperty(prototype, 'constructor', { value: constructor?.proxy ?? ForgedObject }) + return Object.assign(Object.create(prototype) as Record, own) +} + +describe('the enforced raw JSON Schema subset', () => { + it('accepts every JSON root and every supported node', () => { + for (const schema of [ + { type: 'string' }, + { type: 'number' }, + { type: 'integer' }, + { type: 'boolean' }, + { type: 'null' }, + { type: 'array', items: { type: 'string' } }, + { + type: 'object', + properties: { + nested: { type: 'object', properties: {}, additionalProperties: false }, + free: {}, }, - anything: { type: 'array' }, + required: ['nested'], + additionalProperties: true, }, - required: ['file', 'line'], - additionalProperties: true, - }) - expect(schema.type).toBe('object') + { oneOf: [{ type: 'string' }, { type: 'number' }] }, + { description: 'any JSON', title: 'JSON', default: null, examples: [1, 'x'] }, + ]) { + expect(() => { assertSupportedJsonSchema(schema) }, JSON.stringify(schema)).not.toThrow() + } }) - it('rejects a non-object root (scalar/array-rooted schemas)', () => { - expect(violationsOf({ type: 'string' })).toEqual(['schema.type must be "object" (structured output is object-rooted)']) - expect(violationsOf({ type: 'array', items: { type: 'string' } })) - .toContain('schema.type must be "object" (structured output is object-rooted)') + it('retains an object-root guard only at consumers that need it', () => { + expect(assertedObject({ type: 'object' }).type).toBe('object') + for (const schema of [{}, { type: 'string' }, { type: 'array' }, { oneOf: [{ type: 'string' }, { type: 'null' }] }]) { + expect(violationsOf(schema, true)).toEqual(['schema.type must be "object" (structured output is object-rooted)']) + } }) - it('rejects non-object schema nodes and missing/unknown type', () => { - expect(violationsOf('nope')).toEqual(['schema must be a schema object']) + it('rejects non-schema nodes, unknown types, and type arrays', () => { expect(violationsOf(null)).toEqual(['schema must be a schema object']) expect(violationsOf([])).toEqual(['schema must be a schema object']) - expect(violationsOf({})).toEqual(['schema.type must be one of object/array/string/number/integer/boolean/null']) + expect(violationsOf('no')).toEqual(['schema must be a schema object']) expect(violationsOf({ type: 'tuple' })[0]).toMatch(/type must be one of/) - expect(violationsOf({ type: 'object', properties: { a: 'str' } })).toEqual(['schema.properties.a must be a schema object']) - }) - - it('rejects type ARRAYS with a dedicated message', () => { expect(violationsOf({ type: ['string', 'null'] })) .toEqual(['schema.type must be a single type string (type arrays are not supported)']) }) - it('rejects unsupported constraint keywords loudly (never accepted-then-ignored)', () => { - for (const keyword of ['oneOf', 'anyOf', 'allOf', 'not', 'pattern', 'minimum', 'maxLength', '$ref']) { - const bad = violationsOf({ type: 'object', [keyword]: [] }) - expect(bad.some(v => v.includes(`schema.${keyword} is not a supported keyword`))).toBe(true) - } + it('enforces oneOf vocabulary and its minimum branch count', () => { + expect(violationsOf({ oneOf: [] })).toEqual(['schema.oneOf must be an array of at least two schemas']) + expect(violationsOf({ oneOf: [{}] })).toEqual(['schema.oneOf must be an array of at least two schemas']) + expect(violationsOf({ oneOf: 'x' })).toEqual(['schema.oneOf must be an array of at least two schemas']) + expect(violationsOf({ type: 'string', oneOf: [{}, {}] })) + .toEqual(['schema cannot declare both type and oneOf']) + expect(violationsOf({ oneOf: [{ type: 'string' }, { type: 'number' }], items: {} })) + .toEqual(['schema.items is not supported beside oneOf']) + expect(violationsOf({ oneOf: [{ type: 'string' }, { type: 'weird' }] })[0]) + .toContain('schema.oneOf[1].type') + const sparse = new Array(2) + sparse[0] = { type: 'string' } + expect(violationsOf({ oneOf: sparse })) + .toEqual(['schema.oneOf must be an array of at least two schemas']) + const compensatedSparse = new Array(2) + compensatedSparse[0] = { type: 'string' } + Object.defineProperty(compensatedSparse, 'extra', { value: true }) + expect(violationsOf({ oneOf: compensatedSparse })) + .toEqual(['schema.oneOf must be an array of at least two schemas']) + class ExoticBranches extends Array {} + expect(violationsOf({ oneOf: new ExoticBranches({ type: 'string' }, { type: 'null' }) })) + .toEqual(['schema.oneOf must be an array of at least two schemas']) + const explosiveArray = new Proxy([{ type: 'string' }, { type: 'null' }], { + getPrototypeOf() { throw new Error('prototype trap') }, + }) + expect(violationsOf({ oneOf: explosiveArray })) + .toEqual(['schema.oneOf must be an array of at least two schemas']) }) - it('reports EVERY violation, not just the first', () => { - const bad = violationsOf({ + it('rejects unknown and misplaced keywords without accepted-then-ignored behavior', () => { + for (const keyword of ['anyOf', 'allOf', 'not', 'pattern', 'minimum', 'maxLength', '$ref']) { + expect(violationsOf({ type: 'object', [keyword]: [] })[0]).toContain(`schema.${keyword} is not a supported keyword`) + } + expect(violationsOf({ type: 'object', items: {} })) + .toEqual(['schema.items is not supported on type "object"']) + expect(violationsOf({ type: 'array', properties: {} })) + .toEqual(['schema.properties is not supported on type "array"']) + expect(violationsOf({ type: 'object', enum: ['x'] })) + .toEqual(['schema.enum is not supported on type "object"']) + expect(violationsOf({ type: 'array', const: null })) + .toEqual(['schema.const is not supported on type "array"']) + expect(violationsOf({ properties: {}, required: [], additionalProperties: true, items: {}, enum: [], const: null })) + .toEqual([ + 'schema.properties requires type or oneOf', + 'schema.required requires type or oneOf', + 'schema.additionalProperties requires type or oneOf', + 'schema.items requires type or oneOf', + 'schema.enum requires type or oneOf', + 'schema.const requires type or oneOf', + ]) + }) + + it('reports every independent schema violation', () => { + expect(violationsOf({ type: 'object', pattern: 'x', properties: { a: { type: 'weird' }, b: { type: 'string', minimum: 1 } }, - }) - expect(bad.length).toBe(3) + })).toHaveLength(3) }) - it('rejects keywords on the wrong type (items on object, properties on string, enum on object)', () => { - expect(violationsOf({ type: 'object', items: { type: 'string' } })) - .toEqual(['schema.items is not supported on type "object"']) - expect(violationsOf({ type: 'object', properties: { a: { type: 'string', properties: {} } } })) - .toEqual(['schema.properties.a.properties is not supported on type "string"']) - expect(violationsOf({ type: 'object', enum: [1] })) - .toEqual(['schema.enum is not supported on type "object"']) - expect(violationsOf({ type: 'object', properties: { a: { type: 'array', const: 1 } } })) - .toEqual(['schema.properties.a.const is not supported on type "array"']) - }) - - it('validates required: must be string[] naming declared properties', () => { - expect(violationsOf({ type: 'object', required: 'file' })) + it('validates object properties, required names, and openness', () => { + expect(violationsOf({ type: 'object', properties: [] })) + .toEqual(['schema.properties must be an object of schemas']) + expect(violationsOf({ type: 'object', properties: { a: 'x' } })) + .toEqual(['schema.properties.a must be a schema object']) + expect(violationsOf({ type: 'object', required: 'a' })) .toEqual(['schema.required must be an array of strings']) expect(violationsOf({ type: 'object', required: [1] })) .toEqual(['schema.required must be an array of strings']) - expect(violationsOf({ type: 'object', properties: { a: { type: 'string' } }, required: ['b'] })) - .toEqual(['schema.required names "b" which is not in properties']) - expect(violationsOf({ type: 'object', required: ['a'] })) - .toEqual(['schema.required names "a" which is not in properties']) - }) - - it('validates additionalProperties must be boolean and enum/const must be scalars', () => { - expect(violationsOf({ type: 'object', additionalProperties: {} })) + expect(violationsOf({ type: 'object', properties: {}, required: ['missing'] })) + .toEqual(['schema.required names "missing" which is not in properties']) + expect(violationsOf({ type: 'object', additionalProperties: 'yes' })) .toEqual(['schema.additionalProperties must be a boolean']) - expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: [] } } })) - .toEqual(['schema.properties.a.enum must be a non-empty array of scalars']) - expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: [{}] } } })) - .toEqual(['schema.properties.a.enum must be a non-empty array of scalars']) - expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: 'x' } } })) - .toEqual(['schema.properties.a.enum must be a non-empty array of scalars']) - expect(violationsOf({ type: 'object', properties: { a: { type: 'number', enum: [Number.NaN] } } })) - .toEqual(['schema.properties.a.enum must be a non-empty array of scalars']) - expect(violationsOf({ type: 'object', properties: { a: { type: 'string', const: {} } } })) - .toEqual(['schema.properties.a.const must be a scalar']) + expect(violationsOf({ type: 'object', properties: undefined })) + .toEqual(['schema.properties must be an object of schemas']) + expect(violationsOf({ type: 'object', properties: undefined, required: ['missing'] })) + .toEqual([ + 'schema.properties must be an object of schemas', + 'schema.required names "missing" which is not in properties', + ]) + const sparseRequired = new Array(1) + expect(violationsOf({ type: 'object', required: sparseRequired })) + .toEqual(['schema.required must be an array of strings']) }) - it('rejects non-string description/title and non-JSON annotation payloads', () => { - expect(violationsOf({ type: 'object', description: 7 })) - .toEqual(['schema.description must be a string']) - expect(violationsOf({ type: 'object', title: 7 })) - .toEqual(['schema.title must be a string']) - expect(violationsOf({ type: 'object', default: () => 1 })) - .toEqual(['schema.default annotation must be JSON data']) - expect(violationsOf({ type: 'object', examples: [undefined] })) - .toEqual(['schema.examples annotation must be JSON data']) - expect(violationsOf({ type: 'object', examples: [Number.POSITIVE_INFINITY] })) - .toEqual(['schema.examples annotation must be JSON data']) - // A cyclic annotation payload is caught by the JSON-data walk. - const cyclicAnnotation: Record = {} - cyclicAnnotation.self = cyclicAnnotation - expect(violationsOf({ type: 'object', default: cyclicAnnotation })) - .toEqual(['schema.default annotation must be JSON data']) - // Object/array annotations that ARE JSON data pass. - asserted({ type: 'object', default: { a: [1, 'x', null, true] } }) + it('requires type-correct scalar enum and const values', () => { + for (const schema of [ + { type: 'string', enum: ['a'], const: 'a' }, + { type: 'number', enum: [1.5], const: 1.5 }, + { type: 'integer', enum: [1], const: 1 }, + { type: 'boolean', enum: [true], const: true }, + { type: 'null', enum: [null], const: null }, + ]) { + expect(() => { assertSupportedJsonSchema(schema) }, JSON.stringify(schema)).not.toThrow() + } + + expect(violationsOf({ type: 'string', enum: [] })) + .toEqual(['schema.enum must be a non-empty array of string values']) + expect(violationsOf({ type: 'number', enum: ['1'] })) + .toEqual(['schema.enum must be a non-empty array of number values']) + expect(violationsOf({ type: 'integer', enum: [1.5] })) + .toEqual(['schema.enum must be a non-empty array of integer values']) + expect(violationsOf({ type: 'number', enum: [Number.NaN] })) + .toEqual(['schema.enum must be a non-empty array of number values']) + expect(violationsOf({ type: 'number', const: -0 })) + .toEqual(['schema.const must be a number value']) + expect(violationsOf({ type: 'boolean', const: 1 })) + .toEqual(['schema.const must be a boolean value']) + expect(violationsOf({ type: 'string', enum: undefined })) + .toEqual(['schema.enum must be a non-empty array of string values']) + expect(violationsOf({ type: 'string', enum: ['a'], const: 'b' })) + .toEqual(['schema.const must be one of schema.enum when both are declared']) + const sparseEnum = new Array(1) + expect(violationsOf({ type: 'string', enum: sparseEnum })) + .toEqual(['schema.enum must be a non-empty array of string values']) }) - it('rejects a circular schema instead of recursing forever', () => { - const node: Record = { 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']) + expect(violationsOf({ default: Object.defineProperty({}, 'hidden', { value: true }) })) + .toEqual(['schema.default annotation must be lossless JSON data']) + expect(violationsOf({ default: { [Symbol('hidden')]: true } })) + .toEqual(['schema.default annotation must be lossless JSON data']) }) - it('accepts the same subschema object reused in two SIBLING positions (a DAG, not a cycle)', () => { + it('accepts lossless annotation containers from another JavaScript realm', () => { + const schema = runInNewContext(`({ + type: 'object', + properties: { value: { type: 'string', enum: ['x'] } }, + required: ['value'], + default: { x: 1 }, + examples: [[{ ok: true }]], + })`) as unknown + + expect(() => { assertSupportedJsonSchema(schema) }).not.toThrow() + }) + + it('rejects cyclic/exotic schema structure but permits sibling reuse', () => { + const cyclic: Record = { type: 'object' } + cyclic.properties = { self: cyclic } + expect(violationsOf(cyclic)).toEqual(['schema.properties.self is circular']) const leaf = { type: 'string' } - asserted({ type: 'object', properties: { a: leaf, b: leaf } }) + expect(() => { assertSupportedJsonSchema({ type: 'object', properties: { a: leaf, b: leaf } }) }).not.toThrow() + expect(violationsOf({ type: 'object', properties: new Map() })) + .toEqual(['schema.properties must be an object of schemas']) + expect(violationsOf({ type: 'object', properties: { at: new Date(0) } })) + .toEqual(['schema.properties.at must be a schema object']) + + const forgedSchema = recordWithForgedIntrinsicPrototype( + { type: 'object' }, + { oneOf: [{ type: 'string' }, { type: 'null' }] }, + ) + expect(violationsOf(forgedSchema)).toEqual(['schema must be a schema object']) + expect(violationsOf(forgedSchema, true)).toEqual(['schema must be a schema object']) + expect(violationsOf(recordWithForgedIntrinsicPrototype({ type: 'string' }, {}, true))) + .toEqual(['schema must be a schema object']) + const prototypeWithoutConstructor = Object.create(null) as object + expect(violationsOf(Object.create(prototypeWithoutConstructor) as unknown)) + .toEqual(['schema must be a schema object']) + expect(violationsOf(Object.defineProperty({ type: 'string' }, 'hidden', { value: true }))) + .toEqual(['schema must be a schema object']) + expect(violationsOf({ type: 'string', [Symbol('hidden')]: true })) + .toEqual(['schema must be a schema object']) + expect(violationsOf(new Proxy({}, { + getPrototypeOf() { throw new Error('prototype trap') }, + }))).toEqual(['schema must be a schema object']) + expect(violationsOf(new Proxy({}, { + ownKeys() { throw new Error('keys trap') }, + }))).toEqual(['schema must be a schema object']) }) - it('required cannot be satisfied by INHERITED names — `toString` is not a declared property', () => { - // `'toString' in {}` is true via Object.prototype; the declared-property - // contract must be an own-property check. + it('asserts deeply nested raw unions without using the JavaScript call stack', () => { + const depth = 5_000 + let schema: JsonSchemaNode = { type: 'string' } + for (let index = 0; index < depth; index++) schema = { oneOf: [schema, { type: 'null' }] } + + expect(() => { assertSupportedJsonSchema(schema) }).not.toThrow() + }) + + it('uses own-property semantics for required declarations', () => { expect(violationsOf({ type: 'object', properties: {}, required: ['toString'] })) .toEqual(['schema.required names "toString" which is not in properties']) }) - - it('rejects exotic host objects where the subset expects plain JSON structure', () => { - // A Map as `properties` has no own enumerable entries: structurally it - // would read as "no properties" and serialize to {} — lossy, not loud. - expect(violationsOf({ type: 'object', properties: new Map() })) - .toEqual(['schema.properties must be an object of schemas']) - // A Date node is not a schema object even though Object.values(date) is []. - expect(violationsOf({ type: 'object', properties: { at: new Date(0) } })) - .toEqual(['schema.properties.at must be a schema object']) - }) - - it('rejects exotic annotation payloads that would serialize lossily', () => { - expect(violationsOf({ type: 'object', default: new Date(0) })) - .toEqual(['schema.default annotation must be JSON data']) - expect(violationsOf({ type: 'object', examples: [new Map()] })) - .toEqual(['schema.examples annotation must be JSON data']) - }) }) -describe('validateStructuredValue', () => { - const schema = asserted({ - type: 'object', - properties: { - file: { type: 'string' }, - line: { type: 'integer' }, - score: { type: 'number' }, - confirmed: { type: 'boolean' }, - parent: { type: 'null' }, - severity: { type: 'string', enum: ['low', 'high'] }, - kind: { type: 'string', const: 'bug' }, - tags: { type: 'array', items: { type: 'string' } }, - free: { type: 'array' }, - nested: { type: 'object', properties: { x: { type: 'number' } }, required: ['x'], additionalProperties: false }, - }, - required: ['file'], +describe('validateJsonSchemaValue', () => { + it('validates scalar, array, object, and null roots', () => { + expect(validateJsonSchemaValue(asserted({ type: 'string' }), 'x')).toEqual([]) + expect(validateJsonSchemaValue(asserted({ type: 'number' }), 1.5)).toEqual([]) + expect(validateJsonSchemaValue(asserted({ type: 'integer' }), 2)).toEqual([]) + expect(validateJsonSchemaValue(asserted({ type: 'boolean' }), true)).toEqual([]) + expect(validateJsonSchemaValue(asserted({ type: 'null' }), null)).toEqual([]) + expect(validateJsonSchemaValue(asserted({ type: 'array', items: { type: 'string' } }), ['x'])).toEqual([]) + expect(validateJsonSchemaValue(asserted({ type: 'object' }), { x: 1 })).toEqual([]) }) - it('accepts a fully valid value (empty violations)', () => { - expect(validateStructuredValue(schema, { - file: 'a.ts', line: 3, score: 0.5, confirmed: true, parent: null, - severity: 'high', kind: 'bug', tags: ['x'], free: [1, { any: true }], nested: { x: 1 }, - })).toEqual([]) + it('rejects wrong scalar types and lossy numbers', () => { + expect(validateJsonSchemaValue(asserted({ type: 'string' }), 1)).toEqual(['"value" must be a string']) + expect(validateJsonSchemaValue(asserted({ type: 'number' }), '1')).toEqual(['"value" must be a number']) + expect(validateJsonSchemaValue(asserted({ type: 'number' }), Number.NaN)).toEqual(['"value" must be a finite JSON number']) + expect(validateJsonSchemaValue(asserted({ type: 'number' }), -0)).toEqual(['"value" must be a finite JSON number']) + expect(validateJsonSchemaValue(asserted({ type: 'integer' }), 1.5)).toEqual(['"value" must be an integer']) + expect(validateJsonSchemaValue(asserted({ type: 'boolean' }), 'true')).toEqual(['"value" must be a boolean']) + expect(validateJsonSchemaValue(asserted({ type: 'null' }), 0)).toEqual(['"value" must be null']) }) - it('reports missing required and wrong root type', () => { - expect(validateStructuredValue(schema, {})).toEqual(['missing required property "value.file"']) - expect(validateStructuredValue(schema, 'nope')).toEqual(['"value" must be an object']) - expect(validateStructuredValue(schema, [])).toEqual(['"value" must be an object']) + it('enforces scalar enum and const together', () => { + const schema = asserted({ type: 'string', enum: ['a', 'b'], const: 'a' }) + expect(validateJsonSchemaValue(schema, 'a')).toEqual([]) + expect(validateJsonSchemaValue(schema, 'c')).toEqual(['"value" must be one of ["a","b"]']) + expect(validateJsonSchemaValue(schema, 'b')).toEqual(['"value" must be "a"']) }) - it('type-checks every scalar branch with path-qualified messages', () => { - expect(validateStructuredValue(schema, { file: 1 })).toEqual(['"value.file" must be a string']) - expect(validateStructuredValue(schema, { file: 'a', line: 1.5 })).toEqual(['"value.line" must be an integer']) - expect(validateStructuredValue(schema, { file: 'a', line: 'x' })).toEqual(['"value.line" must be an integer']) - expect(validateStructuredValue(schema, { file: 'a', score: 'x' })).toEqual(['"value.score" must be a finite number']) - expect(validateStructuredValue(schema, { file: 'a', score: Number.NaN })).toEqual(['"value.score" must be a finite number']) - expect(validateStructuredValue(schema, { file: 'a', confirmed: 'yes' })).toEqual(['"value.confirmed" must be a boolean']) - expect(validateStructuredValue(schema, { file: 'a', parent: 0 })).toEqual(['"value.parent" must be null']) + it('validates object requiredness, nested values, and raw open defaults', () => { + const open = asserted({ + type: 'object', + properties: { + file: { type: 'string' }, + nested: { + type: 'object', + properties: { line: { type: 'integer' } }, + required: ['line'], + additionalProperties: false, + }, + }, + required: ['file'], + }) + expect(validateJsonSchemaValue(open, { file: 'a', extra: [1], nested: { line: 2 } })).toEqual([]) + expect(validateJsonSchemaValue(open, { nested: { line: 1 } })) + .toEqual(['missing required property "value.file"']) + expect(validateJsonSchemaValue(open, { file: 1, nested: {} })).toEqual([ + '"value.file" must be a string', + 'missing required property "value.nested.line"', + ]) + expect(validateJsonSchemaValue(open, { file: 'a', nested: { line: 1, extra: true } })) + .toEqual(['"value.nested.extra" is not a declared property (additionalProperties: false)']) + expect(validateJsonSchemaValue(open, 'x')).toEqual(['"value" must be an object']) }) - it('enforces enum membership and const equality', () => { - expect(validateStructuredValue(schema, { file: 'a', severity: 'mid' })) - .toEqual(['"value.severity" must be one of ["low","high"]']) - expect(validateStructuredValue(schema, { file: 'a', kind: 'feature' })) - .toEqual(['"value.kind" must be "bug"']) + it('treats present undefined as missing when required, then rejects other lossy objects', () => { + const required = asserted({ type: 'object', properties: { x: {} }, required: ['x'] }) + expect(validateJsonSchemaValue(required, { x: undefined })) + .toEqual(['missing required property "value.x"']) + expect(validateJsonSchemaValue(asserted({ type: 'object' }), { x: undefined })) + .toEqual(['"value" must be a lossless JSON object']) + expect(validateJsonSchemaValue(asserted({ type: 'object' }), new Date(0))) + .toEqual(['"value" must be an object']) }) - it('checks arrays per index; an items-less array accepts anything', () => { - expect(validateStructuredValue(schema, { file: 'a', tags: 'x' })).toEqual(['"value.tags" must be an array']) - expect(validateStructuredValue(schema, { file: 'a', tags: ['ok', 2] })).toEqual(['"value.tags[1]" must be a string']) - expect(validateStructuredValue(schema, { file: 'a', free: [{ deep: [1] }, null] })).toEqual([]) + it('returns a violation instead of throwing for a container with a hostile getter', () => { + const value = Object.defineProperty({}, 'answer', { + enumerable: true, + get() { throw new Error('getter exploded') }, + }) + const schema = asserted({ + type: 'object', + properties: { answer: { type: 'integer' } }, + required: ['answer'], + }) + + expect(validateJsonSchemaValue(schema, value)) + .toEqual(['"value" must be a lossless JSON value']) }) - it('recurses into nested objects: required + additionalProperties: false', () => { - expect(validateStructuredValue(schema, { file: 'a', nested: {} })) - .toEqual(['missing required property "value.nested.x"']) - expect(validateStructuredValue(schema, { file: 'a', nested: { x: 1, y: 2 } })) - .toEqual(['"value.nested.y" is not a declared property (additionalProperties: false)']) - expect(validateStructuredValue(schema, { file: 'a', nested: 3 })) - .toEqual(['"value.nested" must be an object']) + it('validates dense arrays per index and rejects lossy arrays', () => { + const schema = asserted({ type: 'array', items: { type: 'integer' } }) + expect(validateJsonSchemaValue(schema, [1, 2])).toEqual([]) + expect(validateJsonSchemaValue(schema, runInNewContext('[1, 2]'))).toEqual([]) + expect(validateJsonSchemaValue(schema, [1, 1.5])).toEqual(['"value[1]" must be an integer']) + expect(validateJsonSchemaValue(schema, 'x')).toEqual(['"value" must be an array']) + const sparse: number[] = [] + sparse.length = 2 + sparse[0] = 1 + expect(validateJsonSchemaValue(schema, sparse)).toEqual(['"value" must be a dense lossless JSON array']) }) - it('a required key present-but-undefined counts as missing', () => { - expect(validateStructuredValue(schema, { file: undefined })).toEqual(['missing required property "value.file"']) + it('validates exact-one oneOf semantics, including overlap', () => { + const disjoint = asserted({ oneOf: [{ type: 'string' }, { type: 'number' }] }) + expect(validateJsonSchemaValue(disjoint, 'x')).toEqual([]) + expect(validateJsonSchemaValue(disjoint, null)) + .toEqual(['"value" must match exactly one oneOf branch (matched 0)']) + const overlap = asserted({ oneOf: [{ type: 'number' }, { type: 'integer' }] }) + expect(validateJsonSchemaValue(overlap, 1)) + .toEqual(['"value" must match exactly one oneOf branch (matched 2)']) + expect(validateJsonSchemaValue(overlap, 1.5)).toEqual([]) }) - it('inherited properties satisfy nothing: required, additionalProperties, and recursion are own-property only', () => { - // required: ['toString'] must NOT be satisfied by Object.prototype.toString. - expect(validateStructuredValue( + it('validates deeply nested exact-one unions without using the JavaScript call stack', () => { + const depth = 5_000 + let schema: JsonSchemaNode = { type: 'string' } + for (let index = 0; index < depth; index++) schema = { oneOf: [schema, { type: 'null' }] } + assertSupportedJsonSchema(schema) + + expect(validateJsonSchemaValue(schema, 'leaf')).toEqual([]) + expect(validateJsonSchemaValue(schema, 42)) + .toEqual(['"value" must match exactly one oneOf branch (matched 0)']) + }) + + it('an unconstrained schema accepts only lossless JSON values', () => { + const anyJson = asserted({}) + for (const value of [null, true, 1, 'x', [1], { x: null }]) { + expect(validateJsonSchemaValue(anyJson, value), JSON.stringify(value)).toEqual([]) + } + for (const value of [undefined, () => 1, Number.POSITIVE_INFINITY, -0, new Map()]) { + expect(validateJsonSchemaValue(anyJson, value)).toEqual(['"value" must be a lossless JSON value']) + } + const cyclic: Record = {} + cyclic.self = cyclic + expect(validateJsonSchemaValue(anyJson, cyclic)).toEqual(['"value" must be a lossless JSON value']) + const explosive = new Proxy({}, { + ownKeys() { throw new Error('value trap') }, + }) + expect(validateJsonSchemaValue(anyJson, explosive)).toEqual(['"value" must be a lossless JSON value']) + }) + + it('uses own properties for requiredness, recursion, and closed-object checks', () => { + expect(validateJsonSchemaValue( asserted({ type: 'object', properties: { toString: { type: 'string' } }, required: ['toString'] }), {}, )).toEqual(['missing required property "value.toString"']) - // additionalProperties: false must flag an OWN `toString` key even though - // `'toString' in properties` is true via the prototype. - expect(validateStructuredValue( - asserted({ type: 'object', additionalProperties: false }), - { toString: 1 }, - )).toEqual(['"value.toString" is not a declared property (additionalProperties: false)']) - // A declared property the value does NOT carry must not be validated - // against the value's INHERITED member (constructor is a function on - // every plain object's prototype, not a carried property). - expect(validateStructuredValue( + expect(validateJsonSchemaValue(asserted({ type: 'object', additionalProperties: false }), { toString: 1 })) + .toEqual(['"value.toString" is not a declared property (additionalProperties: false)']) + expect(validateJsonSchemaValue( asserted({ type: 'object', properties: { constructor: { type: 'string' } } }), {}, )).toEqual([]) + + const inheritedUnion = Object.assign( + Object.create({ oneOf: [{ type: 'string' }, { type: 'null' }] }) as JsonSchemaNode, + { type: 'object' as const }, + ) + expect(validateJsonSchemaValue(inheritedUnion, {})).toEqual([]) + expect(validateJsonSchemaValue(inheritedUnion, 'x')).toEqual(['"value" must be an object']) + expect(validateJsonSchemaValue( + { type: 'object', properties: undefined } as unknown as JsonSchemaNode, + {}, + )).toEqual([]) + expect(validateJsonSchemaValue( + { type: 'object', required: undefined } as unknown as JsonSchemaNode, + {}, + )).toEqual([]) }) - it('a non-plain object value is not an object in the JSON sense', () => { - expect(validateStructuredValue(asserted({ type: 'object' }), new Date(0))) - .toEqual(['"value" must be an object']) - }) - - it('collects multiple violations across branches in one pass', () => { - expect(validateStructuredValue(schema, { line: 'x', severity: 'mid' })).toEqual([ - 'missing required property "value.file"', - '"value.line" must be an integer', - '"value.severity" must be one of ["low","high"]', - ]) - }) - - it('null-typed const/enum work through the scalar path', () => { - const nullish = asserted({ type: 'object', properties: { a: { type: 'null', const: null } } }) - expect(validateStructuredValue(nullish, { a: null })).toEqual([]) - }) - - it('rejects a non-object properties value in the schema walk', () => { - expect(violationsOf({ type: 'object', properties: [] })) - .toEqual(['schema.properties must be an object of schemas']) - }) - - it('an object schema without properties/required only type-checks its value', () => { - const bare = asserted({ type: 'object' }) - expect(validateStructuredValue(bare, { any: ['thing'] })).toEqual([]) - expect(validateStructuredValue(bare, 7)).toEqual(['"value" must be an object']) - }) - - it('validateStructuredValue throws on a type the assert would never let through (assertNever backstop)', () => { - const forged = { type: 'tuple' } as unknown as StructuredOutputSchema - expect(() => validateStructuredValue(forged, 1)).toThrow(/tuple/) + it('keeps assertNever as a forged-schema backstop', () => { + const forged = { type: 'tuple' } as unknown as JsonSchemaNode + expect(() => validateJsonSchemaValue(forged, 1)).toThrow(/tuple/) }) }) diff --git a/packages/core/tools/tests/properties.spec.ts b/packages/core/tools/tests/properties.spec.ts index 51b49fccd4..e04e9f5c5b 100644 --- a/packages/core/tools/tests/properties.spec.ts +++ b/packages/core/tools/tests/properties.spec.ts @@ -1,61 +1,92 @@ /** * Property-based tests for the tool-schema DSL (the property-testing Agent Note), including - * the the property-testing ↔ runtime-validation composition composition: generated args that satisfy a SchemaSpec must + * the property-testing ↔ runtime-validation composition: generated args that satisfy a ParameterSchemaSpec must * pass validateArgs, and targeted corruptions must be rejected. This closes the * validator/InferArgs drift risk noted in the arg-validation Agent Note. */ import { describe, expect, it } from 'vitest' import fc from 'fast-check' -import { schemaSpecToJsonSchema, validateArgs } from '@deepseek-ai/dsh-tools' -import type { SchemaProp, SchemaSpec } from '@deepseek-ai/dsh-tools' +import { isJsonValue } from '@deepseek-ai/dsh-session' +import { parameterSchemaSpecToJsonSchema, validateArgs } from '@deepseek-ai/dsh-tools' +import type { ParameterPropertySpec, ParameterSchemaSpec, ValueSchemaSpec } from '@deepseek-ai/dsh-tools' + +/** Remove parameter-only requiredness before nesting a schema as an array item. */ +function asValueSchema(prop: ParameterPropertySpec): ValueSchemaSpec { + const { required: _required, ...schema } = prop + return schema +} // A leaf prop arbitrary (no nesting) with optional required/enum. -function leafPropArb(): fc.Arbitrary { +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().filter(value => isJsonValue(value)) } } /** 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 +107,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..ae095b5361 --- /dev/null +++ b/packages/core/tools/tests/schema.spec.ts @@ -0,0 +1,205 @@ +import { describe, expect, expectTypeOf, it } from 'vitest' +import { + JsonSchemaError, + parameterSchemaSpecToJsonSchema, + valueSchemaSpecToJsonSchema, + type InferArgs, + type InferValue, + type JsonValue, + type ParameterSchemaSpec, + type ValueSchemaSpec, +} from '../src/index.ts' + +describe('the unified author schema DSL', () => { + it('compiles every value root and the author-only json node', () => { + expect(valueSchemaSpecToJsonSchema({ type: 'string', enum: ['a', 'b'], const: 'a' })) + .toEqual({ type: 'string', enum: ['a', 'b'], const: 'a' }) + expect(valueSchemaSpecToJsonSchema({ type: 'number' })).toEqual({ type: 'number' }) + expect(valueSchemaSpecToJsonSchema({ type: 'integer' })).toEqual({ type: 'integer' }) + expect(valueSchemaSpecToJsonSchema({ type: 'boolean' })).toEqual({ type: 'boolean' }) + expect(valueSchemaSpecToJsonSchema({ type: 'null' })).toEqual({ type: 'null' }) + expect(valueSchemaSpecToJsonSchema({ type: 'array', items: { type: 'json' } })) + .toEqual({ type: 'array', items: {} }) + expect(valueSchemaSpecToJsonSchema({ type: 'object', additionalProperties: false, properties: {} })) + .toEqual({ type: 'object', additionalProperties: false, properties: {} }) + expect(valueSchemaSpecToJsonSchema({ + type: 'json', + description: 'anything', + title: 'Any JSON', + default: null, + examples: [{ nested: true }], + })).toEqual({ description: 'anything', title: 'Any JSON', default: null, examples: [{ nested: true }] }) + expect(valueSchemaSpecToJsonSchema({ oneOf: [{ type: 'string' }, { type: 'null' }] })) + .toEqual({ oneOf: [{ type: 'string' }, { type: 'null' }] }) + }) + + it('keeps the implicit parameter root open while preserving explicit object openness', () => { + expect(parameterSchemaSpecToJsonSchema({ + closed: { + type: 'object', + additionalProperties: false, + required: true, + properties: { id: { type: 'integer', required: true } }, + }, + open: { type: 'object', additionalProperties: true }, + })).toEqual({ + type: 'object', + properties: { + closed: { + type: 'object', + additionalProperties: false, + properties: { id: { type: 'integer' } }, + required: ['id'], + }, + open: { type: 'object', additionalProperties: true }, + }, + required: ['closed'], + }) + }) + + it('rejects runtime-forged author forms rather than compiling them lossily', () => { + for (const schema of [ + { type: 'object' }, + { oneOf: [{ type: 'string' }] }, + { type: 'number', enum: ['1'] }, + { type: 'string', enum: ['a'], const: 'b' }, + { type: 'integer', const: 1.5 }, + { type: 'json', default: undefined }, + { type: 'array', items: { type: 'string', required: true } }, + { type: 'array', items: 42 }, + { type: 'string', extra: true }, + { type: 'string', oneOf: [{ type: 'string' }, { type: 'null' }] }, + { oneOf: 'not-an-array' }, + { type: 'string', enum: 'a' }, + {}, + null, + ]) { + expect(() => valueSchemaSpecToJsonSchema(schema as ValueSchemaSpec), JSON.stringify(schema)).toThrow(JsonSchemaError) + } + expect(() => parameterSchemaSpecToJsonSchema({ + value: { type: 'string', required: false }, + } as unknown as ParameterSchemaSpec)).toThrow(JsonSchemaError) + expect(() => parameterSchemaSpecToJsonSchema(null as unknown as ParameterSchemaSpec)).toThrow(JsonSchemaError) + expect(() => parameterSchemaSpecToJsonSchema({ bad: 42 } as unknown as ParameterSchemaSpec)).toThrow(JsonSchemaError) + + const symbolKey = Symbol('hidden') + expect(() => parameterSchemaSpecToJsonSchema({ + value: { type: 'string' }, + [symbolKey]: { type: 'number' }, + } as unknown as ParameterSchemaSpec)).toThrow(JsonSchemaError) + const hiddenKey = Object.defineProperty({ value: { type: 'string' } }, 'hidden', { + value: { type: 'number' }, + }) + expect(() => parameterSchemaSpecToJsonSchema(hiddenKey as ParameterSchemaSpec)).toThrow(JsonSchemaError) + const sparseOneOf = new Array(2) + sparseOneOf[0] = { type: 'string' } + expect(() => valueSchemaSpecToJsonSchema({ oneOf: sparseOneOf } as unknown as ValueSchemaSpec)).toThrow(JsonSchemaError) + const decoratedEnum = Object.assign(['a'], { hidden: true }) + expect(() => valueSchemaSpecToJsonSchema({ + type: 'string', + enum: decoratedEnum, + })).toThrow(JsonSchemaError) + }) + + it('rejects cyclic author schemas', () => { + const schema: Record = { 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('compiles deeply nested author unions without using the JavaScript call stack', () => { + const depth = 5_000 + let spec: unknown = { type: 'string' } + for (let index = 0; index < depth; index++) spec = { oneOf: [spec, { type: 'null' }] } + + const compiled = valueSchemaSpecToJsonSchema(spec as ValueSchemaSpec) + + let cursor = compiled + let layers = 0 + while (cursor.oneOf !== undefined) { + cursor = cursor.oneOf[0]! + layers++ + } + expect(layers).toBe(depth) + expect(cursor).toEqual({ type: 'string' }) + }) + + it('preserves a property literally named __proto__ as schema data', () => { + const properties = Object.create(null) as ParameterSchemaSpec + properties.__proto__ = { type: 'string', required: true } + + const schema = parameterSchemaSpecToJsonSchema(properties) + + expect(Object.hasOwn(schema.properties, '__proto__')).toBe(true) + expect(schema.properties.__proto__).toEqual({ type: 'string' }) + expect(schema.required).toEqual(['__proto__']) + }) + + it('infers scalar literals, arrays, objects, json, and exact-one unions', () => { + expectTypeOf>().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('bounds inference for deeply nested author schemas', () => { + type Repeat = + Result['length'] extends Count ? Result : Repeat + type DeepArraySchema = + Levels extends [unknown, ...infer Rest] + ? { type: 'array'; items: DeepArraySchema } + : { type: 'string' } + type PeelArrays = + Levels extends [unknown, ...infer Rest] + ? Value extends (infer Item)[] ? PeelArrays : never + : Value + + type DeepValue = InferValue>> + expectTypeOf>>().toEqualTypeOf() + }) + + it('infers required and optional parameter keys', () => { + expectTypeOf>().toEqualTypeOf<{ path: string; offset?: number; data?: JsonValue }>() + }) + + it('makes invalid author forms compile-time errors', () => { + const symbolKey = Symbol('parameter') + const invalidObjects = { + // @ts-expect-error explicit object schemas require an openness decision + object: { type: 'object' } satisfies ValueSchemaSpec, + // @ts-expect-error oneOf requires at least two branches + oneOf: { oneOf: [{ type: 'string' }] } satisfies ValueSchemaSpec, + // @ts-expect-error scalar enum values must match the node type + enum: { type: 'number', enum: ['1'] } satisfies ValueSchemaSpec, + // @ts-expect-error parameter requiredness is true-or-absent + required: { value: { type: 'string', required: false } } satisfies ParameterSchemaSpec, + // @ts-expect-error parameter maps accept string keys only + symbol: { [symbolKey]: { type: 'string' } } satisfies ParameterSchemaSpec, + } + expect(Object.keys(invalidObjects)).toHaveLength(5) + }) +}) diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index a18adf8593..922173653f 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' const testToolSignal = new AbortController().signal @@ -39,7 +38,11 @@ function tool(name: string, reply = `ran:${name}`): ToolDefinition { name, description: `tool ${name}`, parameters: { type: 'object', properties: {} }, - execute: (): Promise => Promise.resolve([{ type: 'text', text: reply }]), + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value as string }], + }, + execute: (): Promise => Promise.resolve(reply), } } @@ -224,7 +227,7 @@ describe('scoped execution dispatch', () => { ...tool('t'), execute: () => { bodyCalls += 1 - return Promise.resolve([{ type: 'text', text: 'ran:t' }]) + return Promise.resolve('ran:t') }, }) const guard = (execution: Readonly): string => { @@ -256,7 +259,7 @@ describe('scoped execution dispatch', () => { ...tool('t'), execute: () => { bodyCalls += 1 - return Promise.resolve([]) + return Promise.resolve('ran:t') }, }) ctx.tools.guard(() => undefined) @@ -322,14 +325,14 @@ describe('scoped execution dispatch', () => { execute: (args) => { safeCalls += 1 safeArguments = args - return Promise.resolve([{ type: 'text', text: 'safe' }]) + return Promise.resolve('safe') }, }) ctx.tools.register({ ...tool('danger'), execute: () => { dangerCalls += 1 - return Promise.resolve([{ type: 'text', text: 'danger' }]) + return Promise.resolve('danger') }, }) scope.ctx.tools.guard(exec => exec.name === 'danger' ? 'danger denied' : undefined) @@ -382,7 +385,7 @@ describe('scoped execution dispatch', () => { ...tool('t'), execute: () => { bodyCalls += 1 - return Promise.resolve([]) + return Promise.resolve('ran:t') }, }) ctx.on('tools/pre-execute', (_exec, next) => { @@ -444,7 +447,7 @@ describe('scoped execution dispatch', () => { ...tool('t'), execute: (_args, exec) => { observed.push(exec.parent) - return Promise.resolve([{ type: 'text', text: 'ran:t' }]) + return Promise.resolve('ran:t') }, }) ctx.on('tools/pre-execute', (exec, next) => { @@ -561,7 +564,7 @@ describe('scoped execution dispatch', () => { ...tool('t'), execute: () => { bodyCalls += 1 - return Promise.resolve([]) + return Promise.resolve('ran:t') }, }) ctx.on('tools/pre-execute', (_exec, next) => { @@ -604,6 +607,7 @@ describe('scoped execution dispatch', () => { expect(result).toEqual({ content: [{ type: 'text', text: 'ran:t' }], isError: false, + value: 'ran:t', }) }) @@ -622,6 +626,7 @@ describe('scoped execution dispatch', () => { return { content: [{ type: 'text', text: 'outer failure' }], isError: true, + error: { message: 'outer failure' }, } }, { prepend: true }) scope.ctx.on('tools/result', (_exec, result) => { diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 3eb1152a92..37d4663636 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -1,14 +1,14 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from 'cordis' -import { CallId, HarnessError } from '@deepseek-ai/dsh-llm' +import { CallId, HarnessError, type ContentBlock } from '@deepseek-ai/dsh-llm' 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, + defineContentToolFixture, defineTool, JsonSchemaError, parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError, TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, - type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision, - type ToolDispatchExecution, type ToolExecutionResult, + type InferArgs, type JsonValue, type ParameterSchemaSpec, type PreToolDecision, type PostToolDecision, + type JsonSchemaNode, type ToolDefinition, type ToolDispatchExecution, type ToolExecutionResult, type ToolExecutionToken, } from '@deepseek-ai/dsh-tools' const testToolSignal = new AbortController().signal @@ -24,8 +24,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 ?? '' }, }) @@ -53,7 +57,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 } }, @@ -70,7 +74,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' }] }, })) @@ -82,17 +86,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({ signal: testToolSignal, 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({ signal: testToolSignal, callId: CallId('c1'), name: 'meta-tool', arguments: {} }) @@ -100,20 +111,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({ signal: testToolSignal, 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) }) @@ -124,8 +136,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' }, }) @@ -135,9 +151,342 @@ describe('ToolRegistry', () => { }) expect(result.isError).toBe(true) expect(result.content[0]?.type === 'text' && result.content[0].text).toContain('Error:') + expect(result.error).toMatchObject({ info: { name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' } }) 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({ signal: testToolSignal, callId: CallId('lossy'), name: 'lossy-output', arguments: {} }) + const mismatch = await ctx.tools.execute({ signal: testToolSignal, 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('classifies a throwing body snapshot as invalid tool output', async () => { + const ctx = await setup() + const hostile = Object.defineProperty({}, 'value', { + enumerable: true, + get: () => { throw new Error('body snapshot getter exploded') }, + }) + ctx.tools.register(defineTool({ + name: 'hostile-body', + description: 'hostile body', + parameters: {}, + output: { schema: { type: 'json' }, render: () => [] }, + execute: async () => hostile as JsonValue, + })) + + const result = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('hostile-body'), name: 'hostile-body', arguments: {}, + }) + expect(result.error?.message).toContain('value snapshot failed: body snapshot getter exploded') + expect(result.error?.info).toEqual({ name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' }) + }) + + it.each(['render', 'presentationMeta'] as const)('contains a throwing output.%s projector as one failed call', async (projector) => { + const ctx = await setup() + ctx.tools.register(defineTool({ + 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({ signal: testToolSignal, callId: CallId(projector), name: `throwing-${projector}`, arguments: {} }) + expect(result.isError).toBe(true) + expect(result.error?.message) + .toContain(projector === 'render' ? 'renderer exploded' : 'metadata exploded') + expect(result.error?.info).toEqual({ name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' }) + expect('value' in result).toBe(false) + }) + + it.each(['render', 'presentationMeta'] as const)('contains a throwing output.%s snapshot as one failed call', async (projector) => { + const ctx = await setup() + const hostile = Object.defineProperty({}, 'value', { + enumerable: true, + get: () => { throw new Error('snapshot getter exploded') }, + }) + ctx.tools.register(defineTool({ + name: `hostile-${projector}`, + description: projector, + parameters: {}, + output: { + schema: { type: 'string' }, + render: () => projector === 'render' + ? hostile as unknown as ContentBlock[] + : [{ type: 'text', text: 'ok' }], + presentationMeta: () => projector === 'presentationMeta' + ? hostile as unknown as JsonValue + : null, + }, + execute: async () => 'ok', + })) + + const result = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId(`hostile-${projector}`), name: `hostile-${projector}`, arguments: {}, + }) + expect(result.error?.message).toContain('snapshot getter exploded') + expect(result.error?.info).toEqual({ name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' }) + }) + + 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({ signal: testToolSignal, callId: CallId('content'), name: 'projected', arguments: {} }) + replacement = 'value' + const value = await ctx.tools.execute({ signal: testToolSignal, 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({ signal: testToolSignal, 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({ signal: testToolSignal, 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({ signal: testToolSignal, 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({ signal: testToolSignal, 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({ signal: testToolSignal, 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({ signal: testToolSignal, 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({ + signal: testToolSignal, + 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({ signal: testToolSignal, 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({ signal: testToolSignal, 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({ signal: testToolSignal, 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({ signal: testToolSignal, callId: CallId('direct'), name: 'meta-suppression', arguments: {} }) + const nested = await ctx.tools.execute({ + signal: testToolSignal, + 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({ @@ -152,7 +501,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({ signal: testToolSignal, callId: CallId('c2'), name: 'boom', arguments: {} }) expect(thrown.isError).toBe(true) @@ -194,15 +546,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({ signal: testToolSignal, 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 () => { @@ -319,7 +678,7 @@ describe('ToolRegistry', () => { await expect(pending).resolves.toMatchObject({ isError: true, - error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + error: { info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }, }) expect(dispatched).toBe(0) }) @@ -418,7 +777,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: {}, @@ -462,7 +821,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: {}, @@ -513,7 +872,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' } }, @@ -533,7 +892,7 @@ describe('ToolRegistry', () => { ctx.on('tools/post-execute', async (_exec, _result, next) => { order.push('post'); return next() }) const result = await ctx.tools.execute({ signal: testToolSignal, 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']) }) @@ -565,7 +924,7 @@ describe('ToolRegistry', () => { await expect(pending).resolves.toMatchObject({ content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }], isError: true, - error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + error: { info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }, }) expect(dispatched).toBe(0) }) @@ -597,6 +956,7 @@ describe('ToolRegistry', () => { await expect(pending).resolves.toEqual({ content: [{ type: 'text', text: 'Error: policy denied the call' }], isError: true, + error: { message: 'policy denied the call' }, }) expect(dispatched).toBe(0) }) @@ -628,6 +988,7 @@ describe('ToolRegistry', () => { await expect(pending).resolves.toEqual({ content: [{ type: 'text', text: 'Error: gate interrupted' }], isError: true, + error: { message: 'gate interrupted' }, }) expect(dispatched).toBe(0) }) @@ -665,7 +1026,7 @@ describe('ToolRegistry', () => { await expect(pending).resolves.toMatchObject({ isError: true, - error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + error: { info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }, }) expect(dispatched).toBe(0) }) @@ -694,7 +1055,10 @@ describe('ToolRegistry', () => { callId: CallId('cancelled-wrapper'), name: 'must-not-run', arguments: {}, signal: controller.signal, }) - expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }) + expect(result.error).toEqual({ + message: 'tool call aborted before dispatch', + info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + }) expect(dispatched).toBe(0) }) @@ -712,6 +1076,7 @@ describe('ToolRegistry', () => { entered.resolve(undefined) await release.promise return { + value: 'wrapper success', content: [{ type: 'text', text: 'wrapper success' }], isError: false, additionalContexts: [{ @@ -735,7 +1100,7 @@ describe('ToolRegistry', () => { await expect(pending).resolves.toMatchObject({ content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }], isError: true, - error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + error: { info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }, additionalContexts: [{ source: { kind: 'plugin', plugin: 'wrapper' } }], }) expect(dispatched).toBe(0) @@ -751,7 +1116,7 @@ describe('ToolRegistry', () => { content: [{ type: 'text', text: 'completed child work' }], source: { kind: 'plugin', plugin: 'child' }, }) - return [{ type: 'text', text: 'body complete' }] + return 'body complete' }, }) const entered = Promise.withResolvers() @@ -773,7 +1138,7 @@ describe('ToolRegistry', () => { await expect(pending).resolves.toMatchObject({ content: [{ type: 'text', text: 'Error: tool call aborted' }], isError: true, - error: { name: 'AbortError', code: TOOL_ABORTED }, + error: { info: { name: 'AbortError', code: TOOL_ABORTED } }, additionalContexts: [{ source: { kind: 'plugin', plugin: 'child' } }], }) }) @@ -788,7 +1153,7 @@ describe('ToolRegistry', () => { content: [{ type: 'text', text: 'completed child work' }], source: { kind: 'plugin', plugin: 'child' }, }) - return [{ type: 'text', text: 'body complete' }] + return 'body complete' }, }) const entered = Promise.withResolvers() @@ -816,7 +1181,7 @@ describe('ToolRegistry', () => { await expect(pending).resolves.toMatchObject({ content: [{ type: 'text', text: 'Error: tool call aborted' }], isError: true, - error: { name: 'AbortError', code: 'ABORTED' }, + error: { info: { name: 'AbortError', code: TOOL_ABORTED } }, additionalContexts: [ { source: { kind: 'plugin', plugin: 'child' } }, { source: { kind: 'plugin', plugin: 'post' } }, @@ -851,7 +1216,7 @@ describe('ToolRegistry', () => { await expect(pending).resolves.toMatchObject({ content: [{ type: 'text', text: 'Error: wrapper failed' }], isError: true, - error: { name: 'HarnessError', code: 'WRAPPER_FAILURE' }, + error: { info: { name: 'HarnessError', code: 'WRAPPER_FAILURE' } }, }) expect(dispatched).toBe(0) }) @@ -864,7 +1229,7 @@ describe('ToolRegistry', () => { name: 'tool-failure', execute(_args, exec) { entered.resolve(undefined) - return new Promise((_resolve, reject) => { + return new Promise((_resolve, reject) => { exec.signal.addEventListener('abort', () => { reject(new HarnessError('tool failed', 'TOOL_FAILURE')) }, { once: true }) @@ -882,7 +1247,7 @@ describe('ToolRegistry', () => { await expect(pending).resolves.toMatchObject({ content: [{ type: 'text', text: 'Error: tool failed' }], isError: true, - error: { name: 'HarnessError', code: 'TOOL_FAILURE' }, + error: { info: { name: 'HarnessError', code: 'TOOL_FAILURE' } }, }) }) @@ -908,7 +1273,7 @@ describe('ToolRegistry', () => { await expect(pending).resolves.toMatchObject({ content: [{ type: 'text', text: 'Error: post-policy failed' }], isError: true, - error: { name: 'HarnessError', code: 'POST_FAILURE' }, + error: { info: { name: 'HarnessError', code: 'POST_FAILURE' } }, }) }) @@ -923,9 +1288,9 @@ describe('ToolRegistry', () => { execute(_args, exec) { bodySignal = exec.signal entered.resolve(undefined) - if (exec.signal.aborted) return Promise.resolve([]) - return new Promise((resolve) => { - exec.signal.addEventListener('abort', () => { resolve([]) }, { once: true }) + if (exec.signal.aborted) return Promise.resolve('stopped') + return new Promise((resolve) => { + exec.signal.addEventListener('abort', () => { resolve('stopped') }, { once: true }) }) }, }) @@ -950,7 +1315,7 @@ describe('ToolRegistry', () => { await expect(pending).resolves.toMatchObject({ isError: true, - error: { name: 'AbortError', code: 'ABORTED' }, + error: { info: { name: 'AbortError', code: TOOL_ABORTED } }, }) expect(bodySignal?.aborted).toBe(true) expect(replacement.signal.aborted).toBe(false) @@ -984,7 +1349,7 @@ describe('ToolRegistry', () => { it('waits for an uncooperative started body before returning ABORTED', async () => { const ctx = await setup() const entered = Promise.withResolvers() - const release = Promise.withResolvers() + const release = Promise.withResolvers() ctx.tools.register({ ...echoTool, name: 'uncooperative', @@ -1009,10 +1374,10 @@ describe('ToolRegistry', () => { Promise.resolve('pending' as const), ]) expect(state).toBe('pending') - release.resolve([]) + release.resolve('settled') await expect(pending).resolves.toMatchObject({ isError: true, - error: { name: 'AbortError', code: 'ABORTED' }, + error: { info: { name: 'AbortError', code: TOOL_ABORTED } }, additionalContexts: [{ source: { kind: 'plugin', plugin: 'nested' } }], }) }) @@ -1057,7 +1422,10 @@ describe('ToolRegistry', () => { expect(result).toEqual({ content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }], isError: true, - error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + error: { + message: 'tool call aborted before dispatch', + info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + }, }) expect(observedResult).toBe(result) expect(Object.isFrozen(observedExecution)).toBe(true) @@ -1084,6 +1452,7 @@ describe('ToolRegistry', () => { expect(result).toEqual({ content: [{ type: 'text', text: 'Error: tool execution arguments must be losslessly JSON-serializable' }], isError: true, + error: { message: 'tool execution arguments must be losslessly JSON-serializable' }, }) expect(observed).toBe(1) }) @@ -1123,11 +1492,36 @@ describe('ToolRegistry', () => { }) const result = await ctx.tools.execute({ signal: testToolSignal, 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({ + signal: testToolSignal, + 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({ @@ -1157,7 +1551,7 @@ describe('ToolRegistry', () => { name: 'signal-probe', async execute(_args, exec) { seenSignal = exec.signal - return [{ type: 'text' as const, text: 'ok' }] + return 'ok' }, }) @@ -1183,23 +1577,73 @@ 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: ToolDispatchExecution, _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({ signal: testToolSignal, callId: CallId('c1'), name: 'never-runs', arguments: {} }) expect(dispatched).toBe(false) // returning without next() skips core dispatch expect(result.content[0]).toMatchObject({ text: 'short-circuited' }) }) + it('revalidates a cached canonical result returned from a different dispatch', async () => { + const ctx = await setup() + ctx.tools.register({ ...echoTool, name: 'string-output', async execute() { return 'cached' } }) + let objectBodyRan = false + ctx.tools.register(defineTool({ + name: 'object-output', + description: 'Return one closed object.', + parameters: {}, + output: { + schema: { + type: 'object', + properties: { ok: { type: 'boolean', required: true } }, + additionalProperties: false, + }, + render: (_args, value) => [{ type: 'text', text: String(value.ok) }], + }, + execute() { + objectBodyRan = true + return Promise.resolve({ ok: true }) + }, + })) + let cached: ToolExecutionResult | undefined + ctx.on('tools/execute', async (exec, next) => { + if (exec.name === 'string-output') { + cached = await next() + return cached + } + if (exec.name === 'object-output') { + if (cached === undefined) throw new Error('expected the first dispatch result') + return cached + } + return next() + }) + + const first = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('cached-first'), name: 'string-output', arguments: {}, + }) + const second = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('cached-second'), name: 'object-output', arguments: {}, + }) + + expect(first.isError ? undefined : first.value).toBe('cached') + expect(objectBodyRan).toBe(false) + expect(second).toMatchObject({ + isError: true, + error: { info: { name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' } }, + }) + }) + it('preserves additionalContexts supplied by an around-dispatch result', async () => { const ctx = await setup() ctx.tools.register(echoTool) 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' }, @@ -1224,6 +1668,7 @@ describe('ToolRegistry', () => { const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result).toEqual({ content: [{ type: 'text', text: 'Error: wrapper broke' }], + error: { message: 'wrapper broke' }, isError: true, }) }) @@ -1239,6 +1684,7 @@ describe('ToolRegistry', () => { expect(result).toEqual({ content: [{ type: 'text', text: 'Error: permission hook broke' }], + error: { message: 'permission hook broke' }, isError: true, }) }) @@ -1254,6 +1700,7 @@ describe('ToolRegistry', () => { expect(result).toEqual({ content: [{ type: 'text', text: 'Error: post hook broke' }], + error: { message: 'post hook broke' }, isError: true, }) }) @@ -1269,7 +1716,7 @@ describe('ToolRegistry', () => { expect(result).toMatchObject({ isError: true, - error: { name: 'HarnessError', code: 'DENIED' }, + error: { message: 'denied', info: { name: 'HarnessError', code: 'DENIED' } }, }) }) @@ -1289,6 +1736,41 @@ describe('ToolRegistry', () => { }]) }) + it('schemas() snapshots deeply nested parameters without using structured-clone recursion', async () => { + const ctx = await setup() + const depth = 5_000 + let nested: JsonSchemaNode = { type: 'string' } + for (let index = 0; index < depth; index++) nested = { oneOf: [nested, { type: 'null' }] } + ctx.tools.register({ + ...echoTool, + name: 'deep-schema', + parameters: { type: 'object', properties: { nested } }, + }) + + const projected = ctx.tools.schemas()[0]!.parameters as JsonSchemaNode + + let cursor = projected.properties!.nested! + let layers = 0 + while (cursor.oneOf !== undefined) { + cursor = cursor.oneOf[0]! + layers++ + } + expect(layers).toBe(depth) + expect(cursor).toEqual({ type: 'string' }) + }) + + it('rejects schema projection when a raw registration is not lossless JSON', async () => { + const ctx = await setup() + ctx.tools.register({ + ...echoTool, + name: 'lossy-schema', + parameters: { type: 'object', default: Number.NaN }, + }) + + expect(() => ctx.tools.schemas()) + .toThrow('tool "lossy-schema" parameters must be lossless JSON before schema projection') + }) + it('rejects a non-positive or non-finite registration timeout', async () => { const ctx = await setup() expect(() => ctx.tools.register({ ...echoTool, name: 'zero-timeout', timeoutMs: 0 })) @@ -1368,13 +1850,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: { @@ -1387,7 +1869,7 @@ describe('defineTool / schema DSL', () => { }) it('handles empty spec (no properties, no required)', () => { - expect(schemaSpecToJsonSchema({})).toEqual({ + expect(parameterSchemaSpecToJsonSchema({})).toEqual({ type: 'object', properties: {}, }) @@ -1397,19 +1879,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' }, @@ -1430,10 +1914,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 }, }) @@ -1458,6 +1946,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' }]) }) @@ -1468,12 +1957,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 @@ -1488,8 +1978,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'}` }, })) @@ -1526,9 +2020,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 }, }) @@ -1554,8 +2052,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'], @@ -1566,8 +2064,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, @@ -1577,8 +2075,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' }, @@ -1588,8 +2086,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'], @@ -1600,8 +2098,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) @@ -1612,8 +2110,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', }) @@ -1623,13 +2121,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: { @@ -1660,6 +2159,7 @@ describe('schema DSL optional and nested contracts', () => { type: 'array' items: { type: 'object' + additionalProperties: true properties: { host: { type: 'string'; required: true } port: { type: 'number' } @@ -1669,7 +2169,7 @@ describe('schema DSL optional and nested contracts', () => { }> expectTypeOf().toEqualTypeOf<{ names: string[] - servers?: { host: string; port?: number }[] + servers?: ({ host: string; port?: number } & Record)[] }>() }) @@ -1679,20 +2179,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' }, @@ -1774,7 +2276,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 @@ -1784,18 +2286,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([]) }) @@ -1805,39 +2307,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"', @@ -1849,7 +2353,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 @@ -1860,9 +2364,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"', ]) @@ -1872,7 +2376,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 } }, @@ -1890,7 +2394,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 } }, @@ -1899,7 +2403,11 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () }, })) const result = await ctx.tools.execute({ signal: testToolSignal, 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', () => { @@ -1913,7 +2421,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 } }, @@ -1923,7 +2431,10 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () })) const result = await ctx.tools.execute({ signal: testToolSignal, 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 () => { @@ -1938,11 +2449,11 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () }) const result = await ctx.tools.execute({ signal: testToolSignal, 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, @@ -1953,7 +2464,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () }) const result = await ctx.tools.execute({ signal: testToolSignal, 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' }) }) @@ -1964,8 +2475,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 @@ -1975,7 +2490,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' }] }, }) @@ -1983,7 +2498,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' }] }, }) @@ -1991,7 +2506,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' }] }, }) @@ -2000,7 +2515,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') @@ -2008,8 +2523,27 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () }) describe('defineTool presentation (presentCall / presentResult)', () => { + it('preserves inline enum and const literals in inferred arguments', () => { + defineTool({ + name: 'literal-args', + description: 'literal arguments', + parameters: { + mode: { type: 'string', enum: ['read', 'write'], required: true }, + attempt: { type: 'integer', const: 1 }, + }, + output: { + schema: { type: 'null' }, + render: () => [], + }, + async execute(args) { + expectTypeOf(args).toEqualTypeOf<{ mode: 'read' | 'write'; attempt?: 1 }>() + return null + }, + }) + }) + 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' } }, @@ -2029,7 +2563,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 } }, @@ -2040,7 +2574,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/core/tools/tests/ts-types.spec.ts b/packages/core/tools/tests/ts-types.spec.ts index df30a58238..df820fc153 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 type { ToolSchema } from '@deepseek-ai/dsh-llm' +import type { ToolSdkSchema } from '@deepseek-ai/dsh-tools/src/ts-types.ts' +import { parameterSchemaSpecToJsonSchema } from '@deepseek-ai/dsh-tools' describe('jsonSchemaToTs', () => { - it('maps the defineTool DSL subset', () => { + it('maps every unified schema construct', () => { const cases: [unknown, string][] = [ [{ type: 'string' }, 'string'], [{ type: 'number' }, 'number'], + [{ type: 'integer' }, 'number'], [{ type: 'boolean' }, 'boolean'], + [{ type: 'null' }, 'null'], [{ type: 'string', enum: ['a', 'b'] }, '"a" | "b"'], + [{ type: 'number', enum: [1, 2] }, '1 | 2'], + [{ type: 'integer', const: 2 }, '2'], + [{ type: 'boolean', const: true }, 'true'], + [{ type: 'null', const: null }, 'null'], + [{ type: 'string', enum: ['a', 'b'], const: 'a' }, '"a"'], + [{ oneOf: [{ type: 'string' }, { type: 'null' }] }, 'string | null'], [{ type: 'array', items: { type: 'number' } }, 'number[]'], [{ type: 'array', items: { type: 'string', enum: ['x', 'y'] } }, '("x" | "y")[]'], - [{ type: 'array' }, 'unknown[]'], - [{ type: 'object' }, 'Record'], - [{ 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', () => { @@ -83,33 +93,59 @@ describe('jsonSchemaToTs', () => { expect(rendered).not.toContain('tool-*/ over') expect(rendered).toContain(String.raw`tool-*\/ over`) }) + + it('renders deeply nested unions without using the JavaScript call stack', () => { + const depth = 5_000 + let schema: unknown = { type: 'string' } + for (let index = 0; index < depth; index++) schema = { oneOf: [schema, { type: 'null' }] } + + const rendered = jsonSchemaToTs(schema) + + expect(rendered.startsWith('string | null')).toBe(true) + expect(rendered.length).toBe('string'.length + depth * ' | null'.length) + }) }) describe('renderToolsSdk', () => { - const bash: ToolSchema = { + const bash: ToolSdkSchema = { name: 'bash', description: 'Run a shell command.', - parameters: schemaSpecToJsonSchema({ command: { type: 'string', required: true } }) as unknown as Record, + parameters: parameterSchemaSpecToJsonSchema({ command: { type: 'string', required: true } }) as unknown as Record, + output: { + type: 'object', + additionalProperties: false, + properties: { exitCode: { type: 'integer' } }, + required: ['exitCode'], + }, } - const exotic: ToolSchema = { + const exotic: ToolSdkSchema = { name: 'my-mcp.tool', description: 'Exotic name.', - parameters: schemaSpecToJsonSchema({}) as unknown as Record, + parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record, + output: { type: 'array', items: { type: 'string' } }, } it('declares every tool in lexicographic order with quoted keys for exotic names', () => { const text = renderToolsSdk([exotic, bash]) + expect(text).toContain('interface ToolArgsMap {') + expect(text).toContain('interface ToolOutputMap {') + expect(text).toContain('type ToolName = keyof ToolOutputMap') + expect(text).toContain('declare class ToolCallError extends Error') + expect(text).toContain('readonly toolName: ToolName;') expect(text).toContain('declare const tools: {') - expect(text.indexOf('bash(args:')).toBeGreaterThan(0) - expect(text).toContain('"my-mcp.tool"(args:') - expect(text.indexOf('bash(args:')).toBeLessThan(text.indexOf('"my-mcp.tool"(args:')) - expect(text).toContain('): Promise;') + expect(text).toContain('type JsonValue = null | boolean | number | string') + expect(text.indexOf('bash: {')).toBeGreaterThan(0) + expect(text).toContain('"my-mcp.tool":') + expect(text.indexOf('bash:')).toBeLessThan(text.indexOf('"my-mcp.tool":')) + expect(text).toContain('exitCode: number;') + expect(text).toContain('"my-mcp.tool": string[];') + expect(text).toContain('[K in ToolName]: (args: ToolArgsMap[K]) => Promise;') expect(text).toContain('/** Run a shell command. */') // The fixed instruction lines the model relies on. expect(text).toContain('erasable syntax only') - expect(text).toContain('rejects with an `Error`') + expect(text).toContain('rejects with `ToolCallError`') expect(text).toContain('sequentially, even under `Promise.all`') - expect(text).toContain('JSON-serializable') + expect(text).toContain('lossless JSON') }) it('is deterministic: same tool set, byte-identical text regardless of input order', () => { @@ -119,6 +155,8 @@ describe('renderToolsSdk', () => { }) it('renders an empty declaration for an empty tool set', () => { - expect(renderToolsSdk([])).toContain('declare const tools: {}') + const text = renderToolsSdk([]) + expect(text).toContain('interface ToolArgsMap {}') + expect(text).toContain('interface ToolOutputMap {}') }) }) diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index 876bf54f75..18cd0b5221 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -219,7 +219,8 @@ describe('dsh-acp-demo composition', () => { name, description: name, parameters: {}, - execute: async () => [], + output: { schema: { type: 'null' }, render: () => [] }, + execute: async () => null, }) } const assembly = await ctx.get('systemPrompt')!.assemble() 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 47687029a5..1ac506a098 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -562,7 +562,8 @@ describe('dsh-agent-spine-demo bundle', () => { name, description: name, parameters: {}, - execute: async () => [], + output: { schema: { type: 'null' }, render: () => [] }, + execute: async () => null, }) } const assembly = await ctx.get('systemPrompt')!.assemble() diff --git a/packages/examples/cli-demo/tests/cli-demo.spec.ts b/packages/examples/cli-demo/tests/cli-demo.spec.ts index adf3999445..9bd8088dbe 100644 --- a/packages/examples/cli-demo/tests/cli-demo.spec.ts +++ b/packages/examples/cli-demo/tests/cli-demo.spec.ts @@ -104,7 +104,13 @@ describe('dsh-cli-demo app composition', () => { }) ctx.skills.register({ name: 'cli-skill', description: 'CLI skill', source: 'runtime', content: 'body' }) for (const name of ['alpha', 'zulu']) { - ctx.tools.register({ name, description: name, parameters: {}, execute: async () => [] }) + ctx.tools.register({ + name, + description: name, + parameters: {}, + output: { schema: { type: 'null' }, render: () => [] }, + execute: async () => null, + }) } expect(JSON.stringify(await composePrefix(ctx))).toContain('- `cli-skill`: CLI...') expect((await ctx.systemPrompt.assemble()).tools.map(tool => tool.name)).toEqual([ diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts index e477924f3f..65eafff1cc 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 7c2ab16705..c497d7e428 100644 --- a/packages/fs/tool-fs-search/tests/integration.spec.ts +++ b/packages/fs/tool-fs-search/tests/integration.spec.ts @@ -99,7 +99,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () it('classifies an invalid glob as SEARCH_INVALID_PATTERN', async () => { const result = await call('glob', { pattern: '[' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_INVALID_PATTERN' }) + expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_INVALID_PATTERN' } }) }) }) @@ -142,13 +142,13 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () it('classifies a real rg regex error as SEARCH_INVALID_PATTERN', async () => { const result = await call('grep', { pattern: '(unclosed' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'SEARCH_INVALID_PATTERN' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_INVALID_PATTERN' } }) }) it('classifies a missing target as SEARCH_FAILED', async () => { const result = await call('grep', { pattern: 'x', path: 'no-such-dir' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } }) }) }) @@ -179,14 +179,14 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () signal: controller.signal, }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }) + expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }) }) it('an unusable session cwd (spawn failure) is SEARCH_FAILED', async () => { const gone = join(dir, 'deleted-session-dir') const result = await call('glob', { pattern: '*' }, { session: { header: { id: 'session-int', cwd: gone } } }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_FAILED' }) + expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } }) expect(text(result)).toContain('could not start') }) }) diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index 9e4aa6483e..1951d97a8d 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -15,7 +15,7 @@ import { Context } from 'cordis' import { join } from 'node:path' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH, type ToolExecutionToken } from '@deepseek-ai/dsh-tools' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill' @@ -148,7 +148,12 @@ async function expectSetupRejects(options: SetupOptions, message: RegExp): Promi const agent = (cwd?: string) => ({ session: { header: { id: 'session-1', ...cwd !== undefined ? { cwd } : {} } } }) let callCounter = 0 -function call(ctx: Context, name: string, args: unknown, options: { agent?: object; signal?: AbortSignal } = {}) { +function call( + ctx: Context, + name: string, + args: unknown, + options: { agent?: object; signal?: AbortSignal; parent?: ToolExecutionToken } = {}, +) { return ctx.tools.execute({ signal: testToolSignal, callId: CallId(`call-${++callCounter}`), @@ -156,6 +161,7 @@ function call(ctx: Context, name: string, args: unknown, options: { agent?: obje arguments: args, ...options.agent ? { agent: options.agent as never } : {}, ...options.signal ? { signal: options.signal } : {}, + ...options.parent ? { parent: options.parent } : {}, }) } @@ -320,7 +326,7 @@ describe('workdir derivation and signal forwarding', () => { bash.handler = () => runResult('', { timedOut: true, timeoutMs: 1234, exitCode: null, signal: 'SIGTERM' }) const result = await call(ctx, 'glob', { pattern: '*' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'SEARCH_ABORTED' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_ABORTED' } }) expect(text(result)).toContain('timed out after 1234ms') }) @@ -331,7 +337,7 @@ describe('workdir derivation and signal forwarding', () => { bash.handler = () => { throw new Error('aborted before spawn') } const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }) + expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }) expect(bash.specs).toHaveLength(0) }) @@ -346,7 +352,7 @@ describe('workdir derivation and signal forwarding', () => { const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' }) + expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_ABORTED' } }) expect(text(result)).toContain('aborted before completion') }) @@ -357,7 +363,7 @@ describe('workdir derivation and signal forwarding', () => { const result = await call(ctx, 'glob', { pattern: '*' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' }) + expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_ABORTED' } }) expect(text(result)).toContain('aborted before completion') }) @@ -367,7 +373,7 @@ describe('workdir derivation and signal forwarding', () => { bash.handler = () => { throw new Error('spawn bash ENOENT') } const result = await call(ctx, 'glob', { pattern: '*' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_FAILED' }) + expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } }) expect(text(result)).toContain('could not start') }) }) @@ -388,7 +394,7 @@ describe('exit semantics and failure classification', () => { const { ctx, bash } = await setup() bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: regex parse error:\n (\nerror: unclosed group', truncated: false } }) const result = await call(ctx, 'grep', { pattern: '(' }) - expect(result.error).toMatchObject({ code: 'SEARCH_INVALID_PATTERN' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_INVALID_PATTERN' } }) expect(text(result)).toContain('regex parse error') }) @@ -396,14 +402,14 @@ describe('exit semantics and failure classification', () => { const { ctx, bash } = await setup() bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: error parsing glob \'[\': unclosed character class', truncated: false } }) const result = await call(ctx, 'glob', { pattern: '[' }) - expect(result.error).toMatchObject({ code: 'SEARCH_INVALID_PATTERN' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_INVALID_PATTERN' } }) }) it('a missing rg binary classifies as SEARCH_FAILED naming ripgrep', async () => { const { ctx, bash } = await setup() bash.handler = () => runResult('', { exitCode: 127, stderr: { text: 'bash: line 1: rg: command not found', truncated: false } }) const result = await call(ctx, 'glob', { pattern: '*' }) - expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } }) expect(text(result)).toContain('requires ripgrep (rg)') // The same classification holds from either evidence alone: the 127 exit // with silent stderr, or a shell's command-not-found text on another exit. @@ -417,7 +423,7 @@ describe('exit semantics and failure classification', () => { const { ctx, bash } = await setup() bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: missing.dir: IO error: no such file or directory', truncated: false } }) const result = await call(ctx, 'grep', { pattern: 'x', path: 'missing.dir' }) - expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } }) expect(text(result)).toContain('IO error') }) @@ -425,7 +431,7 @@ describe('exit semantics and failure classification', () => { const { ctx, bash } = await setup() bash.handler = () => runResult('', { exitCode: 3 }) const result = await call(ctx, 'glob', { pattern: '*' }) - expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } }) expect(text(result)).toContain('exit 3') }) @@ -443,7 +449,7 @@ describe('exit semantics and failure classification', () => { const { ctx, bash } = await setup() bash.handler = () => runResult('', { exitCode: null, signal: 'SIGKILL' }) const result = await call(ctx, 'grep', { pattern: 'x' }) - expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } }) expect(text(result)).toContain('SIGKILL') }) @@ -451,7 +457,7 @@ describe('exit semantics and failure classification', () => { const { ctx, bash } = await setup() bash.handler = () => runResult('', { exitCode: null, signal: null }) const result = await call(ctx, 'glob', { pattern: '*' }) - expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_FAILED' } }) }) }) @@ -469,7 +475,7 @@ describe('raw output acquisition', () => { const { ctx, bash } = await setup({ config: { rawOutputMaxBytes: 16 } }) bash.handler = () => runResult('', { stdout: { text: 'x', truncated: true, spillPath: '/does/not/get-read' } }) const result = await call(ctx, 'glob', { pattern: '*' }) - expect(result.error).toMatchObject({ code: 'SEARCH_RAW_OUTPUT_OVERFLOW' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_RAW_OUTPUT_OVERFLOW' } }) expect(text(result)).toContain('narrow pattern, path, or include') }) @@ -480,7 +486,7 @@ describe('raw output acquisition', () => { const { ctx, bash } = await setup({ config: { rawOutputMaxBytes: 16 } }) bash.handler = () => runResult(`${'x'.repeat(64)}\n`) const result = await call(ctx, 'grep', { pattern: 'x' }) - expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_RAW_OUTPUT_OVERFLOW' }) + expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_RAW_OUTPUT_OVERFLOW' } }) expect(text(result)).toContain('narrow pattern, path, or include') }) @@ -488,7 +494,7 @@ describe('raw output acquisition', () => { const { ctx, bash } = await setup() bash.handler = () => runResult('', { stdout: { text: 'partial', truncated: true } }) const result = await call(ctx, 'grep', { pattern: 'x' }) - expect(result.error).toMatchObject({ code: 'SEARCH_RAW_OUTPUT_OVERFLOW' }) + expect(result.error).toMatchObject({ info: { code: 'SEARCH_RAW_OUTPUT_OVERFLOW' } }) }) }) @@ -497,6 +503,8 @@ describe('glob results', () => { const { ctx, bash } = await setup() bash.handler = () => runResult('/sessions/s1/src/a.ts\n/elsewhere/b.ts\nrel/c.ts\n') const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/sessions/s1') }) + if (result.isError) throw new Error('expected glob success') + expect(result.value).toEqual({ paths: [join('src', 'a.ts'), '/elsewhere/b.ts', 'rel/c.ts'] }) expect(text(result)).toBe(`${join('src', 'a.ts')}\n/elsewhere/b.ts\nrel/c.ts`) }) @@ -516,9 +524,15 @@ describe('glob results', () => { it('caps at globMaxResults and saves the FULL sorted list through spillStore', async () => { const { ctx, bash, spill } = await setup({ config: { globMaxResults: 2 }, spill: true }) + ctx.on('tools/post-execute', async () => ({ + kind: 'accept', + additionalContexts: [{ content: [{ type: 'text', text: 'glob context' }], source: { kind: 'plugin', plugin: 'test' } }], + })) bash.handler = () => runResult('a.ts\nb.ts\nc.ts\nd.ts\n') const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') }) expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected glob success') + expect(result.value).toEqual({ paths: ['a.ts', 'b.ts', 'c.ts', 'd.ts'] }) expect(text(result)).toBe('a.ts\nb.ts\n\n(Showing 2 of 4 paths. Full sorted result stored at: /spill/glob-results.txt. Use the fake retrieval hint.)') expect(spill?.saves).toHaveLength(1) expect(spill?.saves[0]).toMatchObject({ @@ -528,6 +542,7 @@ describe('glob results', () => { content: 'a.ts\nb.ts\nc.ts\nd.ts', }) expect(spill?.saves[0]?.source.callId).toBeDefined() + expect(result.additionalContexts?.[0]?.content).toEqual([{ type: 'text', text: 'glob context' }]) }) it('does not create a spill file when the result fits inline', async () => { @@ -538,6 +553,36 @@ describe('glob results', () => { expect(spill?.saves).toHaveLength(0) }) + it('preserves a downstream canonical value replacement instead of spilling the old value', async () => { + const { ctx, bash, spill } = await setup({ config: { globMaxResults: 1 }, spill: true }) + ctx.on('tools/post-execute', async () => ({ + kind: 'accept' as const, + value: { paths: ['replacement-a.ts', 'replacement-b.ts'] }, + })) + bash.handler = () => runResult('old-a.ts\nold-b.ts\n') + + const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') }) + + if (result.isError) throw new Error('expected glob replacement success') + expect(result.value).toEqual({ paths: ['replacement-a.ts', 'replacement-b.ts'] }) + expect(text(result)).toContain('replacement-a.ts') + expect(text(result)).not.toContain('old-a.ts') + expect(spill?.saves).toHaveLength(0) + }) + + it('keeps the full nested Code value without creating a surface spill', async () => { + const { ctx, bash, spill } = await setup({ config: { globMaxResults: 2 }, spill: true }) + bash.handler = () => runResult('a.ts\nb.ts\nc.ts\nd.ts\n') + const result = await call(ctx, 'glob', { pattern: '*.ts' }, { + agent: agent('/w'), + parent: Symbol('run_code') as ToolExecutionToken, + }) + if (result.isError) throw new Error('expected glob success') + expect(result.value).toEqual({ paths: ['a.ts', 'b.ts', 'c.ts', 'd.ts'] }) + expect(text(result)).toBe('a.ts\nb.ts\n\n(Showing 2 of 4 paths. The complete result could not be saved; narrow pattern or path to see more.)') + expect(spill?.saves).toHaveLength(0) + }) + it.each([ ['no spill backend loaded', { fail: false, spill: false, ownerless: false }], ['saveText fails', { fail: true, spill: true, ownerless: false }], @@ -566,6 +611,14 @@ describe('grep results', () => { ].join('\n')) const result = await call(ctx, 'grep', { pattern: 'const' }) expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected grep success') + expect(result.value).toEqual({ + matches: [ + { path: 'a.ts', lineNumber: 3, line: 'const x = 1' }, + { path: 'a.ts', lineNumber: 9, line: 'const y = 2' }, + { path: 'b.ts', lineNumber: 1, line: 'const z = 3' }, + ], + }) expect(text(result)).toBe('Found 3 matches\n\na.ts\nLine 3: const x = 1\nLine 9: const y = 2\n\nb.ts\nLine 1: const z = 3') }) @@ -588,6 +641,8 @@ describe('grep results', () => { // Use a multibyte straddle instead: 'aé' repeated — cut at 7 bytes: a(1)é(2)a(1)é(2)=6 +a(1)=7 → next é straddles: trimmed. bash.handler = () => runResult(`${matchLine('a.txt', 1, 'aéaéaéaé')}\n`) const result = await call(ctx, 'grep', { pattern: 'a' }) + if (result.isError) throw new Error('expected grep success') + expect(result.value).toEqual({ matches: [{ path: 'a.txt', lineNumber: 1, line: 'aéaéaéaé' }] }) expect(text(result)).toContain('Line 1: aéaéa (line truncated)') }) @@ -605,6 +660,10 @@ describe('grep results', () => { it('caps at grepMaxMatches and spills the full formatted match list', async () => { const { ctx, bash, spill } = await setup({ config: { grepMaxMatches: 2 }, spill: true }) + ctx.on('tools/post-execute', async () => ({ + kind: 'accept', + additionalContexts: [{ content: [{ type: 'text', text: 'grep context' }], source: { kind: 'plugin', plugin: 'test' } }], + })) bash.handler = () => runResult([ matchLine('a.ts', 1, 'one'), matchLine('a.ts', 2, 'two'), @@ -612,12 +671,66 @@ describe('grep results', () => { '', ].join('\n')) const result = await call(ctx, 'grep', { pattern: 'e' }, { agent: agent('/w') }) + if (result.isError) throw new Error('expected grep success') + expect(result.value).toEqual({ + matches: [ + { path: 'a.ts', lineNumber: 1, line: 'one' }, + { path: 'a.ts', lineNumber: 2, line: 'two' }, + { path: 'b.ts', lineNumber: 3, line: 'three' }, + ], + }) expect(text(result)).toBe('Found 2 of 3 matches\n\na.ts\nLine 1: one\nLine 2: two\n\n(Full grep result stored at: /spill/grep-results.txt. Use the fake retrieval hint.)') expect(spill?.saves[0]).toMatchObject({ source: { toolName: 'grep', label: 'result' }, suggestedName: 'grep-results.txt', content: 'Found 3 matches\n\na.ts\nLine 1: one\nLine 2: two\n\nb.ts\nLine 3: three', }) + expect(result.additionalContexts?.[0]?.content).toEqual([{ type: 'text', text: 'grep context' }]) + }) + + it('preserves a downstream canonical value replacement instead of spilling the old matches', async () => { + const { ctx, bash, spill } = await setup({ config: { grepMaxMatches: 1 }, spill: true }) + ctx.on('tools/post-execute', async () => ({ + kind: 'accept' as const, + value: { + matches: [ + { path: 'replacement.ts', lineNumber: 7, line: 'first' }, + { path: 'replacement.ts', lineNumber: 8, line: 'second' }, + ], + }, + })) + bash.handler = () => runResult(`${matchLine('old.ts', 1, 'old')}\n`) + + const result = await call(ctx, 'grep', { pattern: 'old' }, { agent: agent('/w') }) + + if (result.isError) throw new Error('expected grep replacement success') + expect(result.value).toEqual({ + matches: [ + { path: 'replacement.ts', lineNumber: 7, line: 'first' }, + { path: 'replacement.ts', lineNumber: 8, line: 'second' }, + ], + }) + expect(text(result)).toContain('replacement.ts') + expect(text(result)).not.toContain('old.ts') + expect(spill?.saves).toHaveLength(0) + }) + + it('keeps every nested Code match in the value without creating a surface spill', async () => { + const { ctx, bash, spill } = await setup({ config: { grepMaxMatches: 1 }, spill: true }) + bash.handler = () => runResult(`${matchLine('a.ts', 1, 'one')}\n${matchLine('b.ts', 2, 'two')}\n`) + const result = await call(ctx, 'grep', { pattern: 'o' }, { + agent: agent('/w'), + parent: Symbol('run_code') as ToolExecutionToken, + }) + if (result.isError) throw new Error('expected grep success') + expect(result.value).toEqual({ + matches: [ + { path: 'a.ts', lineNumber: 1, line: 'one' }, + { path: 'b.ts', lineNumber: 2, line: 'two' }, + ], + }) + expect(text(result)).toBe('Found 1 of 2 matches\n\na.ts\nLine 1: one\n\n(The complete result could not be saved; narrow pattern, path, or include to see more.)') + expect(spill?.saves).toHaveLength(0) }) it('reports the unsaved remainder when capped with no spill backend', async () => { @@ -660,7 +773,7 @@ describe('rg --json transport failures (SEARCH_FAILED)', () => { bash.handler = () => runResult(`${line}\n`) const result = await call(ctx, 'grep', { pattern: 'x' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_FAILED' }) + expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } }) }) }) 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 d3bb9a2803..951c0b7b57 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 policy (approved mode > session override // > backend default, plus the session cwd root) BEFORE anything executes. @@ -115,11 +133,10 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void { } // Record the observed version (a no-op when no policy plugin listens). ctx.emit('fs/observed', target, outcome.version, exec) - // An edit necessarily changes content, so result metadata carries at least one applied hunk. - const diffs = computeHunkDiffs(input.filePath, outcome.before, outcome.after) return { - content: [{ type: 'text', text: formatEditOutput(target.displayPath, input.replaceAll) }], - meta: { diffs }, + path: target.displayPath, + before: outcome.before, + after: outcome.after, } }, // Pure display: a diff card of the literal replacement (old_string → new_string), derived diff --git a/packages/fs/tool-fs/src/read-render.ts b/packages/fs/tool-fs/src/read-render.ts index 943ff98f61..7e581bb22c 100644 --- a/packages/fs/tool-fs/src/read-render.ts +++ b/packages/fs/tool-fs/src/read-render.ts @@ -37,9 +37,9 @@ export interface FileTextLine { export interface WindowResult { /** Returned lines, already numbered. */ lines: FileTextLine[] - /** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */ + /** Exact total line count in the file. */ totalLines: number - /** Whether selected output hit the byte cap before EOF or the requested limit. */ + /** Whether selected output hit the byte cap. */ truncatedByBytes: boolean } @@ -49,9 +49,9 @@ export interface FileReadOutcome { offset: number /** Returned lines, already numbered. */ lines: FileTextLine[] - /** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */ + /** Exact total line count in the file. */ totalLines: number - /** Whether selected output hit the byte cap before EOF or the requested limit. */ + /** Whether selected output hit the byte cap. */ truncatedByBytes?: true } @@ -60,11 +60,10 @@ interface WindowAccumulator { totalLines: number outputBytes: number truncatedByBytes: boolean - done: boolean } function newAccumulator(): WindowAccumulator { - return { lines: [], totalLines: 0, outputBytes: 0, truncatedByBytes: false, done: false } + return { lines: [], totalLines: 0, outputBytes: 0, truncatedByBytes: false } } function truncateLine(line: string, maxLineLength: number): string { @@ -77,13 +76,12 @@ function lineByteSize(line: string, currentLineCount: number): number { function consumeLine(acc: WindowAccumulator, rawLine: string, request: ReadWindow): void { acc.totalLines += 1 - if (acc.totalLines < request.offset || acc.lines.length >= request.limit) return + if (acc.truncatedByBytes || acc.totalLines < request.offset || acc.lines.length >= request.limit) return const text = truncateLine(rawLine, request.maxLineLength) const bytes = lineByteSize(text, acc.lines.length) if (acc.outputBytes + bytes > request.maxBytes) { acc.truncatedByBytes = true - acc.done = true return } acc.outputBytes += bytes @@ -102,8 +100,9 @@ function finish(acc: WindowAccumulator, request: ReadWindow, displayPath: string } /** - * Build one window from streamed or whole-file chunks, enforcing line and byte caps and throwing - * `FS_NOT_FOUND` when the requested offset is past EOF. + * Build one window from streamed or whole-file chunks, enforcing line and byte caps while still + * scanning to an exact total line count, and throwing `FS_NOT_FOUND` when the requested offset is + * past EOF. * @param chunks - decoded text chunks in file order; chunk boundaries carry no meaning. * @param request - the resolved window; the caller has already applied its defaults and caps. * @param displayPath - the caller-facing path used in the offset-out-of-range error. @@ -137,7 +136,6 @@ export async function buildWindow( appendToLineBuffer(chunk.slice(startPos, newlinePos)) flushLine() startPos = newlinePos + 1 - if (acc.done) return finish(acc, request, displayPath) } appendToLineBuffer(chunk.slice(startPos)) } diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index cb1409987d..4e299d9a79 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, input.filePath)) @@ -107,17 +142,17 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void { target.displayPath, ) - const outcome: FileReadOutcome = { + const outcome = { + path: target.displayPath, offset: input.offset, lines: window.lines, totalLines: window.totalLines, - ...window.truncatedByBytes ? { truncatedByBytes: true } : {}, } // Record the observed version (a no-op when no policy plugin listens). The // read already succeeded; an fs/observed listener is contractually a // synchronous, side-effect-only recorder. ctx.emit('fs/observed', target, info.version, exec) - return [{ type: 'text', text: formatReadOutput(target.displayPath, outcome) }] + return outcome }, // Pure display: a generic card titled by the file with the read window appended (`Read // foo.txt (5 - 8)`), `read` kind (icon), and a follow-along location whose line is the diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index 1e92b66612..ba96dbe40c 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 policy (approved mode > session override // > backend default, plus the session cwd root) BEFORE anything executes; @@ -94,12 +118,11 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void { } // Record the observed version (a no-op when no policy plugin listens). ctx.emit('fs/observed', target, outcome.version, exec) - // Overwrites carry applied hunks. Creates have no prior text, so result presentation uses - // the args-derived whole-file diff instead. - const diffs = outcome.before !== null ? computeHunkDiffs(input.filePath, outcome.before, outcome.after) : [] return { - content: [{ type: 'text', text: formatWriteOutput(target.displayPath, outcome) }], - ...diffs.length > 0 ? { meta: { diffs } } : {}, + path: target.displayPath, + operation: outcome.operation, + before: outcome.before, + after: outcome.after, } }, // Pure display: a diff card (an editor renders write as a new-file / full- replace diff). diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index a0dbc11294..e911b9addc 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -70,7 +70,7 @@ describe('default deployment (with dsh-fs-policy)', () => { await writeFile(join(dir, 'a.txt'), 'original') const result = await call('write', { file_path: 'a.txt', content: 'clobber' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' }) + expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } }) expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('original') }) @@ -88,7 +88,7 @@ describe('default deployment (with dsh-fs-policy)', () => { await writeFile(join(dir, 'a.txt'), 'changed-externally') // out-of-band change const result = await call('write', { file_path: 'a.txt', content: 'replaced' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' }) + expect(result.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) }) }) @@ -105,7 +105,7 @@ describe('default deployment (with dsh-fs-policy)', () => { await writeFile(join(dir, 'bin'), Buffer.from([0x00, 0x01, 0x02])) const result = await call('read', { file_path: 'bin' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_NOT_TEXT' }) + expect(result.error).toMatchObject({ info: { code: 'FS_NOT_TEXT' } }) }) it('paginates a multi-line file with offset/limit', async () => { @@ -130,7 +130,7 @@ describe('default deployment (with dsh-fs-policy)', () => { await writeFile(join(dir, 'a.txt'), 'hello world') const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' }) + expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } }) expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world') }) @@ -154,7 +154,7 @@ describe('default deployment (with dsh-fs-policy)', () => { await writeFile(join(dir, 'a.txt'), 'goodbye') // out-of-band change removes 'world' const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' }) + expect(result.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) }) it('rejects an ambiguous match without replace_all', async () => { @@ -162,7 +162,7 @@ describe('default deployment (with dsh-fs-policy)', () => { await call('read', { file_path: 'a.txt' }) const result = await call('edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_AMBIGUOUS_EDIT' }) + expect(result.error).toMatchObject({ info: { code: 'FS_AMBIGUOUS_EDIT' } }) expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('a a a') }) @@ -190,7 +190,7 @@ describe('default deployment (with dsh-fs-policy)', () => { // The model-facing edit still rejects: the read did not emit fs/observed. const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' }) + expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } }) }) }) @@ -263,14 +263,14 @@ describe('bare provider (no dsh-fs-policy)', () => { it('edit of a MISSING target reports FS_STALE_VERSION even on the unguarded path', async () => { const result = await call('edit', { file_path: 'missing.txt', old_string: 'a', new_string: 'b' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' }) + expect(result.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) }) it('edit still enforces literal-match codes (FS_EDIT_NOT_FOUND), unrelated to freshness', async () => { await writeFile(join(dir, 'a.txt'), 'hello world') const result = await call('edit', { file_path: 'a.txt', old_string: 'absent', new_string: 'x' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_EDIT_NOT_FOUND' }) + expect(result.error).toMatchObject({ info: { code: 'FS_EDIT_NOT_FOUND' } }) }) it('neither write nor edit stats in the tool on the bare path', async () => { @@ -354,11 +354,11 @@ describe('signal, concurrency, and the fs/observed contract', () => { await writeFile(join(dir, 'a.txt'), 'hello') const read = await callSig(AbortSignal.abort(), 'read', { file_path: 'a.txt' }) expect(read.isError).toBe(true) - expect(read.error).toMatchObject({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }) + expect(read.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }) const write = await callSig(AbortSignal.abort(), 'write', { file_path: 'new.txt', content: 'x' }) expect(write.isError).toBe(true) - expect(write.error).toMatchObject({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }) + expect(write.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }) await expect(readFile(join(dir, 'new.txt'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) // Read first (un-aborted, SAME session owner) so the edit clears the @@ -366,7 +366,7 @@ describe('signal, concurrency, and the fs/observed contract', () => { expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false) const edit = await callSig(AbortSignal.abort(), 'edit', { file_path: 'a.txt', old_string: 'hello', new_string: 'bye' }) expect(edit.isError).toBe(true) - expect(edit.error).toMatchObject({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }) + expect(edit.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }) expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello') // unchanged }) @@ -381,7 +381,7 @@ describe('signal, concurrency, and the fs/observed contract', () => { ]) const errors = [one, two].filter(r => r.isError) expect(errors).toHaveLength(1) - expect(errors[0]?.error).toMatchObject({ code: 'FS_STALE_VERSION' }) + expect(errors[0]?.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) // The world is consistent: exactly one edit landed. const onDisk = await readFile(join(dir, 'a.txt'), 'utf8') expect(onDisk === 'ONE value here' || onDisk === 'base TWO here').toBe(true) @@ -410,7 +410,7 @@ describe('signal, concurrency, and the fs/observed contract', () => { new_string: 'edited', }) expect(edit.isError).toBe(true) - expect(edit.error).toMatchObject({ code: 'FS_STALE_VERSION' }) + expect(edit.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('newer current content\n') }) diff --git a/packages/fs/tool-fs/tests/read-render.spec.ts b/packages/fs/tool-fs/tests/read-render.spec.ts index ab4d2a618b..c2afaf002e 100644 --- a/packages/fs/tool-fs/tests/read-render.spec.ts +++ b/packages/fs/tool-fs/tests/read-render.spec.ts @@ -86,6 +86,7 @@ describe('buildWindow', () => { it('caps output at a custom maxBytes', async () => { const result = await buildWindow(whole('aaaa\nbbbb\ncccc'), { offset: 1, limit: 10, maxLineLength: 2000, maxBytes: 9 }, 'f') expect(result.lines.map(l => l.text)).toEqual(['aaaa', 'bbbb']) + expect(result.totalLines).toBe(3) expect(result.truncatedByBytes).toBe(true) }) }) @@ -105,6 +106,7 @@ describe('buildWindow', () => { it('caps output bytes mid-stream', async () => { const big = Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n') const result = await buildWindow(chunked(big, 512), READ_ALL, 'f') + expect(result.totalLines).toBe(2000) expect(result.truncatedByBytes).toBe(true) }) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index df55bb99f4..bc193cc23a 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -195,6 +195,13 @@ describe('read tool', () => { fs.files.set('key:a.txt', 'hello\nworld') const result = await call(ctx, 'read', { file_path: 'a.txt' }) expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected read success') + expect(result.value).toEqual({ + path: '/abs/a.txt', + offset: 1, + lines: [{ number: 1, text: 'hello' }, { number: 2, text: 'world' }], + totalLines: 2, + }) expect(text(result)).toBe(`/abs/a.txt file @@ -205,6 +212,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 }) @@ -260,7 +276,7 @@ describe('read tool', () => { const { ctx } = await setup() const result = await call(ctx, 'read', { file_path: 'missing.txt' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_NOT_FOUND' }) + expect(result.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } }) }) it('rejects a non-regular target', async () => { @@ -269,7 +285,7 @@ describe('read tool', () => { fs.stat = async () => ({ version: FsVersion('v1'), type: 'directory' }) const result = await call(ctx, 'read', { file_path: 'd' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) + expect(result.error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } }) }) it('streams a large file (size at/above the cap) instead of reading whole', async () => { @@ -335,6 +351,8 @@ describe('write tool', () => { const { ctx, fs } = await setup() const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' }, { session: { header: {} } }) expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected write success') + expect(result.value).toEqual({ path: '/abs/a.txt', operation: 'create', before: null, after: 'hi' }) expect(text(result)).toContain('Created file') expect(fs.writeIntents).toEqual([{ kind: 'createIfAbsent' }]) }) @@ -351,7 +369,7 @@ describe('write tool', () => { fs.rejectWith = new FsError('blocked', 'FS_STALE_VERSION') const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ name: 'FsError', code: 'FS_STALE_VERSION' }) + expect(result.error).toMatchObject({ info: { name: 'FsError', code: 'FS_STALE_VERSION' } }) }) }) @@ -362,6 +380,8 @@ describe('edit tool', () => { fs.files.set('key:a.txt', 'a') await call(ctx, 'read', { file_path: 'a.txt' }, { session }) const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session }) + if (result.isError) throw new Error('expected edit success') + expect(result.value).toEqual({ path: '/abs/a.txt', before: 'a', after: 'b' }) expect(text(result)).toBe('The file /abs/a.txt has been updated successfully.') }) @@ -400,7 +420,7 @@ describe('edit tool', () => { fs.files.set('key:a.txt', 'hello') const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session: { header: {} } }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' }) + expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } }) }) }) @@ -498,27 +518,27 @@ describe('result-time contextual diff (meta + presentResult)', () => { expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }] }) }) - it('write CREATE: no before-version → no meta, but presentResult still renders a whole-file diff card', async () => { - // A create has no prior content (no `meta`), yet the completed card must be a `diff` — an + it('write CREATE: an empty applied-diff projection still falls back to the whole-file diff card', async () => { + // A create has no prior content, yet the completed card must be a `diff` — an // ACP tool_call_update.content REPLACES the call's content, so a non-diff result would // clobber the pending new-file diff. const { ctx } = await setup() const session = { header: {} } const result = await call(ctx, 'write', { file_path: 'new.txt', content: 'fresh\n' }, { session }) expect(result.isError).toBe(false) - expect(result.meta).toBeUndefined() + expect(result.meta).toEqual({ diffs: [] }) const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'new.txt', content: 'fresh\n' }, result) expect(view).toEqual({ card: 'diff', title: 'Write new.txt', diffs: [{ path: 'new.txt', oldText: null, newText: 'fresh\n' }] }) }) - it('write OVERWRITE with identical content: a before exists but yields no hunk → no meta, presentResult falls back to a whole-file diff', async () => { + it('write OVERWRITE with identical content: an empty applied-diff projection falls back to a whole-file diff', async () => { const { ctx, fs } = await setup() const session = { header: {} } fs.files.set('key:a.txt', 'same\n') await call(ctx, 'read', { file_path: 'a.txt' }, { session }) const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'same\n' }, { session }) expect(result.isError).toBe(false) - expect(result.meta).toBeUndefined() + expect(result.meta).toEqual({ diffs: [] }) const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'same\n' }, result) expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: null, newText: 'same\n' }] }) }) @@ -583,6 +603,9 @@ describe('read caps are plugin config', () => { const { ctx, fs } = await setupWith({ readMaxBytes: 9 }) fs.files.set('key:a.txt', 'aaaa\nbbbb\ncccc') const result = await call(ctx, 'read', { file_path: 'a.txt' }) + expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected read success') + expect(result.value).toMatchObject({ totalLines: 3 }) expect(text(result)).toContain('Output capped.') expect(text(result)).not.toContain('cccc') }) 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 faaf9d1c76..7b2ccdc347 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -98,9 +98,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. */ @@ -196,7 +199,7 @@ describe('goal tool execution authority', () => { it('rejects agentless, driverless, non-human, and live-child creation', async () => { const { ctx, root } = await harness() const agentless = await execute(ctx, 'get_goal', {}) - expect(agentless.error?.code).toBe('GOAL_TOOL_AGENT_REQUIRED') + expect(agentless.error?.info?.code).toBe('GOAL_TOOL_AGENT_REQUIRED') openTurn(root, { kind: 'user' }) const driverless = await ctx.tools.execute({ @@ -206,12 +209,12 @@ describe('goal tool execution authority', () => { arguments: {}, agent: root.agent, }) - expect(driverless.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + expect(driverless.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') closeTurn(root, 1) openTurn(root, { kind: 'plugin', plugin: 'test' }) const nonHuman = await execute(ctx, 'create_goal', { objective: 'forged' }, root.agent) - expect(nonHuman.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') + expect(nonHuman.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') closeTurn(root, 2) const child = stubAgent('goal-tool-child') @@ -219,7 +222,7 @@ describe('goal tool execution authority', () => { ctx.agents.announce(child.agent) openTurn(child, { kind: 'user' }) const childResult = await execute(ctx, 'create_goal', { objective: 'child goal' }, child.agent) - expect(childResult.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') + expect(childResult.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') }) it('rejects stale agent objects and agents outside running status through the executor', async () => { @@ -227,11 +230,11 @@ describe('goal tool execution authority', () => { openTurn(root, { kind: 'user' }) const stale = { ...root.agent } const staleResult = await execute(ctx, 'get_goal', {}, stale, stale) - expect(staleResult.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + expect(staleResult.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') root.setStatus('idle') const idleResult = await execute(ctx, 'get_goal', {}, root.agent) - expect(idleResult.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + expect(idleResult.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') }) it('treats a fork resumed as a runtime root as direct-human authority', async () => { @@ -261,12 +264,12 @@ describe('goal tool execution authority', () => { it('rejects calls before a turn and after its end boundary', async () => { const { ctx, root } = await harness() const before = await execute(ctx, 'get_goal', {}, root.agent) - expect(before.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + expect(before.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') const turn = openTurn(root, { kind: 'user' }) closeTurn(root, turn) const after = await execute(ctx, 'get_goal', {}, root.agent) - expect(after.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + expect(after.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') }) it('rejects terminal reporting without human input or a current goal round', async () => { @@ -275,11 +278,11 @@ describe('goal tool execution authority', () => { const result = await execute(ctx, 'update_goal', { goal_id: 'goal-missing', revision: 1, action: 'complete', }, root.agent) - expect(result.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') + expect(result.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') const malformed = await execute(ctx, 'update_goal', { goal_id: 'goal-missing', revision: 1, action: 'pause', objective: 'probe', }, root.agent) - expect(malformed.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') + expect(malformed.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') }) it('accepts direct human steering in a goal-sourced root turn', async () => { @@ -307,7 +310,7 @@ describe('goal tool execution authority', () => { ctx.agents.register(other.agent) openTurn(other, { kind: 'user' }) const result = await execute(ctx, 'get_goal', {}, other.agent, root.agent) - expect(result.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + expect(result.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') }) }) @@ -378,7 +381,7 @@ describe('goal tool state transitions', () => { const { ctx, root } = await harness() openTurn(root, { kind: 'user' }) const invalidCreate = await execute(ctx, 'create_goal', { objective: ' ' }, root.agent) - expect(invalidCreate.error?.code).toBe('GOAL_INVALID_OBJECTIVE') + expect(invalidCreate.error?.info?.code).toBe('GOAL_INVALID_OBJECTIVE') const created = ctx.goals.create(root.agent, { objective: 'valid' }) const replacement = await execute(ctx, 'update_goal', { goal_id: created.id, @@ -386,26 +389,26 @@ describe('goal tool state transitions', () => { action: 'pause', objective: 'not valid for pause', }, root.agent) - expect(replacement.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + expect(replacement.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') const terminalUpdate = await execute(ctx, 'update_goal', { goal_id: created.id, revision: created.revision, action: 'complete', max_goal_rounds: 2, }, root.agent) - expect(terminalUpdate.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + expect(terminalUpdate.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') const blockedWithoutReason = await execute(ctx, 'update_goal', { goal_id: created.id, revision: created.revision, action: 'blocked', }, root.agent) - expect(blockedWithoutReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + expect(blockedWithoutReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') const blockedWithEmptyReason = await execute(ctx, 'update_goal', { goal_id: created.id, revision: created.revision, action: 'blocked', blocked_reason: ' ', }, root.agent) - expect(blockedWithEmptyReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + expect(blockedWithEmptyReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') const completeWithReason = await execute(ctx, 'update_goal', { goal_id: created.id, revision: created.revision, action: 'complete', blocked_reason: 'Not a blocker.', }, root.agent) - expect(completeWithReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + expect(completeWithReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') const editWithReason = await execute(ctx, 'update_goal', { goal_id: created.id, revision: created.revision, @@ -413,11 +416,11 @@ describe('goal tool state transitions', () => { objective: 'still valid', blocked_reason: 'Not valid for edit.', }, root.agent) - expect(editWithReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + expect(editWithReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') const malformedRef = await execute(ctx, 'update_goal', { goal_id: '', revision: 0, action: 'edit', objective: 'x', }, root.agent) - expect(malformedRef.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + expect(malformedRef.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') }) it('allows exact goal rounds to complete but not edit or pause', async () => { @@ -429,7 +432,7 @@ describe('goal tool state transitions', () => { const edit = await execute(ctx, 'update_goal', { goal_id: created.id, revision: created.revision, action: 'edit', objective: 'forbidden', }, root.agent) - expect(edit.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') + expect(edit.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') const complete = await execute(ctx, 'update_goal', { goal_id: created.id, revision: created.revision, action: 'complete', }, root.agent) @@ -451,7 +454,7 @@ describe('goal tool state transitions', () => { action: 'blocked', blocked_reason: 'The required credential is still unavailable.', }, root.agent) - expect(result.error?.code).toBe('GOAL_TOOL_BLOCK_THRESHOLD') + expect(result.error?.info?.code).toBe('GOAL_TOOL_BLOCK_THRESHOLD') closeTurn(root, turn) } openTurn(root, { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 3 }) diff --git a/packages/guard/repeat-tool-guard/src/index.ts b/packages/guard/repeat-tool-guard/src/index.ts index 2265c583d5..47eaadbfdb 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 b3c90021a5..2049d416fb 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' @@ -26,8 +26,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 } @@ -334,11 +334,11 @@ describe('fold onto the downstream decision', () => { expect(results[1]!.data.content).toEqual([{ type: 'text', text: 'nope' }]) }) - it('preserves a downstream accept content replacement while folding', async () => { + it('preserves a downstream canonical value replacement while folding', async () => { const ctx = await harness({ thresholds: [2] }) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, - content: [{ type: 'text' as const, text: 'replaced' }], + value: [{ type: 'text' as const, text: 'replaced' }], })) const adapter = new MockAdapter([ toolCallResponse('c1', 'probe', { q: 1 }), diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 5a5d33427d..3595d9445c 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 953b4befd0..e59ae24e31 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' @@ -143,7 +143,7 @@ describe('hooks-claude bridge — PreToolUse', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'danger', {}), textResponse('done')]) const ctx = await harness(dir, adapter) let ran = false - ctx.tools.register(defineTool({ name: 'danger', description: 'd', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'danger', description: 'd', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'use danger' }]) await waitForIdle(ctx, agent) @@ -166,7 +166,7 @@ describe('hooks-claude bridge — PreToolUse', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'safe', {}), textResponse('done')]) const ctx = await harness(dir, adapter) let ran = false - ctx.tools.register(defineTool({ name: 'safe', description: 's', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ran ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'safe', description: 's', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ran ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'use safe' }]) await waitForIdle(ctx, agent) @@ -188,7 +188,7 @@ describe('hooks-claude bridge — PostToolUse', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(dir, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'raw output' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'raw output' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -209,7 +209,7 @@ describe('hooks-claude bridge — PostToolUse', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(dir, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -233,7 +233,7 @@ describe('hooks-claude bridge — PostToolUse', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(dir, adapter) let ran = false - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts index e303c589be..4a894e6c2f 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' @@ -72,7 +72,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'capture.sh', `#!/usr/bin/env bash\ncat > "${cap}"\n`) }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} }) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -102,7 +102,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter, { pluginRoot: d, projectDir: d }) ctx.logger.warn = warn as never - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -118,7 +118,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) ctx.logger.warn = warn as never let sawArgs: unknown - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: { command: { type: 'string' } }, async execute(args) { sawArgs = args; return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: { command: { type: 'string' } }, async execute(args) { sawArgs = args; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -149,7 +149,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const ctx = await harness(path, new MockAdapter([])) let ran = false - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) // Call execute() directly with NO agent — the bridge's no-agent/no-turn path. const { CallId } = await import('@deepseek-ai/dsh-llm') const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {} }) @@ -164,7 +164,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -189,7 +189,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 }) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -269,7 +269,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'x' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'x' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -283,7 +283,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -327,7 +327,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) let ran = false - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -342,7 +342,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -382,7 +382,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) let ran = false - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -397,7 +397,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -416,7 +416,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) let ran = false - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -433,7 +433,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -453,7 +453,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) let ran = false - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -536,16 +536,16 @@ export function defineCoverageCases(group: CoverageGroup): void { expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' }) }) - it('folds the bridge PostToolUse context onto a downstream ACCEPT that replaces content', async () => { + it('folds the bridge PostToolUse context onto a downstream canonical value replacement', async () => { // The bridge hook adds context; a later post-execute listener accepts with a - // content rewrite. Both the rewrite and the bridge context survive. + // canonical replacement. Both the replacement and the bridge context survive. const d = dir() const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'rewritten-result' }] })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: [{ type: 'text' as const, text: 'rewritten-result' }] })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -560,7 +560,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, additionalContexts: [{ @@ -590,7 +590,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) @@ -615,7 +615,7 @@ export function defineCoverageCases(group: CoverageGroup): void { // yields a HookOutput with exitCode undefined → the `exitCode` spread false arm. const bash = ctx.bash bash.run = (() => Promise.reject(new Error('executor down'))) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -663,7 +663,7 @@ export function defineCoverageCases(group: CoverageGroup): void { await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const { SessionId } = await import('@deepseek-ai/dsh-session') const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } }) diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index 7d05950957..c2ec2613d5 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -231,8 +231,7 @@ export function apply(ctx: Context, config: Config): void { return { ...downstream, additionalContexts: prependContext(context, downstream.additionalContexts) } } return { - kind: 'accept', - ...downstream.content !== undefined ? { content: downstream.content } : {}, + ...downstream, additionalContexts: prependContext(context, downstream.additionalContexts), } }) diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 684650104a..00c0e6c14d 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 d0f0df92f6..0c46ddea7a 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' @@ -63,7 +63,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) @@ -146,13 +146,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') @@ -165,7 +165,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: [{ @@ -190,7 +190,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) @@ -217,7 +217,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') @@ -230,7 +230,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) @@ -244,7 +244,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 '' @@ -255,7 +255,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') @@ -268,7 +268,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') @@ -291,7 +291,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') @@ -327,7 +327,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) @@ -368,7 +368,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) @@ -381,7 +381,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 @@ -397,7 +397,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') @@ -410,7 +410,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') @@ -422,7 +422,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') @@ -439,7 +439,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 } } @@ -451,7 +451,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({ signal: testToolSignal, callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } }) expect(ran).toBe(false) // denied @@ -462,7 +462,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({ signal: testToolSignal, callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } }) expect(result.isError).toBeFalsy() @@ -475,7 +475,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') @@ -572,7 +572,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 } } @@ -588,7 +588,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 @@ -622,7 +622,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/host/runtime/src/api-proxy.ts b/packages/host/runtime/src/api-proxy.ts index 0621fbecb9..0c2523c39a 100644 --- a/packages/host/runtime/src/api-proxy.ts +++ b/packages/host/runtime/src/api-proxy.ts @@ -10,7 +10,7 @@ import { stat } from 'node:fs/promises' import type { Context } from 'cordis' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import type { ApiProxy, HistoryEntry, HostFrame, MuxFrame, SessionSummary, ToolEventView } from '@deepseek-ai/dsh-host-apiproxy/api' import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' @@ -153,7 +153,7 @@ export interface ApiProxyDefaults { /** The tool/call payload fields the presenter path reads. */ interface ToolCallData { callId: string; name: string; arguments: string } /** The tool/result payload fields the presenter path reads. */ -interface ToolResultData { callId: string; content: ContentBlock[]; isError: boolean; meta?: unknown } +interface ToolResultData { callId: string; content: ContentBlock[]; isError: boolean; meta?: JsonValue } /** * Compute the render intent for a tool/call or tool/result event through the diff --git a/packages/host/runtime/tests/api-proxy-view.spec.ts b/packages/host/runtime/tests/api-proxy-view.spec.ts index d000742262..72c4b51d05 100644 --- a/packages/host/runtime/tests/api-proxy-view.spec.ts +++ b/packages/host/runtime/tests/api-proxy-view.spec.ts @@ -1,8 +1,9 @@ /** * Tool-card view computation over the mux live path: three standard card types - * arrive on the frame, a presenterless tool ships no view field, and a throwing - * presenter soft-falls to no view (the event still ships). Result pairing works - * both through the live open-call table and the backscan fallback after + * arrive on the frame, a presenterless tool ships no view field, a call-only + * presenter keeps raw result content out of the view payload, and a throwing + * presenter soft-falls to no view (the event still ships). Result pairing + * works both through the live open-call table and the backscan fallback after * turn/end cleared it. */ @@ -12,7 +13,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore 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 type { ContentBlock } from '@deepseek-ai/dsh-llm' import { CallId } from '@deepseek-ai/dsh-llm' import type { Session, SessionId } from '@deepseek-ai/dsh-session' @@ -24,13 +25,13 @@ import { createApiProxy } from '../src/api-proxy.ts' const reply = (text: string): Promise => Promise.resolve([{ type: 'text', text }]) function tool(name: string, presenters: Pick): ToolDefinition { - return { + return defineContentToolFixture({ name, description: `tool ${name}`, - parameters: { type: 'object', properties: {} }, + parameters: {}, execute: () => reply(`ran:${name}`), ...presenters, - } + }) } async function harness(): Promise<{ ctx: Context }> { @@ -50,6 +51,9 @@ async function harness(): Promise<{ ctx: Context }> { ctx.tools.register(tool('diffy', { presentCall: () => ({ card: 'diff', title: 'Write f.txt', diffs: [{ path: 'f.txt', oldText: null, newText: 'x' }] }), })) + ctx.tools.register(tool('call-only', { + presentCall: () => ({ card: 'generic', title: 'program', kind: 'execute', rawInput: 'return value' }), + })) ctx.tools.register(tool('plain', {})) ctx.tools.register(tool('boom', { presentCall: () => { throw new Error('presenter exploded') }, @@ -73,13 +77,16 @@ describe('mux live view computation', () => { const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const abort = new AbortController() const stream = api.events.mux({ rpcId: RpcId('t-mux'), payload: {} }, abort.signal) - const collected = collect(stream, 7, abort) + const collected = collect(stream, 9, abort) + const rawResult = `RAW_RESULT:${'x'.repeat(64 * 1024)}` const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-gen'), name: 'gen', arguments: '{}' }) session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-term'), name: 'term', arguments: '{"cmd":"echo hi"}' }) session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-diff'), name: 'diffy', arguments: '{}' }) + session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-call-only'), name: 'call-only', arguments: '{}' }) + session.append('tool/result', { turn: 1, step: 1, callId: CallId('c-call-only'), content: [{ type: 'text', text: rawResult }], isError: false }, { surfaceOp: 'append' }) session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-plain'), name: 'plain', arguments: '{}' }) session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-boom'), name: 'boom', arguments: '{}' }) session.append('tool/result', { turn: 1, step: 1, callId: CallId('c-gen'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' }) @@ -93,6 +100,15 @@ describe('mux live view computation', () => { expect(byCall.get('tool/call:c-gen')?.view).toEqual({ for: 'call', view: { card: 'generic', title: 'gen call' } }) expect(byCall.get('tool/call:c-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'echo hi' } }) expect(byCall.get('tool/call:c-diff')?.view?.view.card).toBe('diff') + expect(byCall.get('tool/call:c-call-only')?.view).toEqual({ + for: 'call', + view: { card: 'generic', title: 'program', kind: 'execute', rawInput: 'return value' }, + }) + const callOnlyResult = byCall.get('tool/result:c-call-only') + expect('view' in (callOnlyResult ?? {})).toBe(false) + const serializedResult = JSON.stringify(callOnlyResult) + expect(serializedResult.indexOf(rawResult)).toBeGreaterThanOrEqual(0) + expect(serializedResult.indexOf(rawResult)).toBe(serializedResult.lastIndexOf(rawResult)) // No presenter → the frame carries no view property at all. expect('view' in (byCall.get('tool/call:c-plain') ?? {})).toBe(false) // Throwing presenter → soft-fall: event ships, no view. diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index b424718187..4dc1c06bd6 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/llm/llm/src/call-config.ts b/packages/llm/llm/src/call-config.ts index fe723ec162..fd6ecf9df4 100644 --- a/packages/llm/llm/src/call-config.ts +++ b/packages/llm/llm/src/call-config.ts @@ -58,7 +58,8 @@ export function isAgentLoopRequest(request: GenerateOptions): boolean { } /** - * Deep-freeze a value in place, guarding cycles, so later mutation throws. + * Deep-freeze a value in place with an iterative traversal, guarding cycles, + * so later mutation throws without imposing a JavaScript call-stack depth cap. * {@link AbortSignal} objects are deliberately skipped because they are the * request's live cancellation channel and freezing them breaks abort. * @param value - the value to freeze in place. @@ -66,16 +67,31 @@ export function isAgentLoopRequest(request: GenerateOptions): boolean { */ export function deepFreeze(value: T): T { const seen = new WeakSet() - const walk = (node: unknown): void => { - if (node === null || typeof node !== 'object') return - if (node instanceof AbortSignal) return - if (seen.has(node)) return + const pending: ( + | { kind: 'visit'; node: unknown } + | { kind: 'property'; source: Record; key: string } + )[] = [{ kind: 'visit', node: value }] + while (pending.length > 0) { + const task = pending.pop() + /* v8 ignore next -- the loop condition guarantees one pending task. */ + if (task === undefined) continue + if (task.kind === 'property') { + pending.push({ kind: 'visit', node: task.source[task.key] }) + continue + } + const node = task.node + if (node === null || typeof node !== 'object') continue + if (node instanceof AbortSignal) continue + if (seen.has(node)) continue seen.add(node) Object.freeze(node) - for (const key of Object.keys(node)) { - walk((node as Record)[key]) + const keys = Object.keys(node) + for (let index = keys.length - 1; index >= 0; index--) { + const key = keys[index] + /* v8 ignore next -- the loop is bounded by the captured key count. */ + if (key === undefined) continue + pending.push({ kind: 'property', source: node as Record, key }) } } - walk(value) return value } diff --git a/packages/llm/llm/tests/call-config.spec.ts b/packages/llm/llm/tests/call-config.spec.ts index 6479ec8f85..a5426e815f 100644 --- a/packages/llm/llm/tests/call-config.spec.ts +++ b/packages/llm/llm/tests/call-config.spec.ts @@ -56,6 +56,26 @@ describe('deepFreeze', () => { deepFreeze(cyclic) expect(Object.isFrozen(cyclic)).toBe(true) }) + + it('freezes nesting deeper than the JavaScript call stack', () => { + const depth = 5_000 + const root: unknown[] = [] + let cursor = root + for (let index = 0; index < depth; index++) { + const child: unknown[] = [] + cursor.push(child) + cursor = child + } + + deepFreeze(root) + + cursor = root + for (let index = 0; index < depth; index++) { + expect(Object.isFrozen(cursor)).toBe(true) + cursor = cursor[0] as unknown[] + } + expect(Object.isFrozen(cursor)).toBe(true) + }) }) describe('agent-loop request identity', () => { diff --git a/packages/lsp/tool-lsp/README.md b/packages/lsp/tool-lsp/README.md index 4a844d2537..e2f15ba79a 100644 --- a/packages/lsp/tool-lsp/README.md +++ b/packages/lsp/tool-lsp/README.md @@ -8,7 +8,7 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). In `lsp` accepts `operation` (`goToDefinition` | `findReferences` | `goToImplementation` | `hover`), `file_path`, `line`, and `character`. `line` and `character` are positive, one-based UTF-16 cursor coordinates; the tool converts them to the seam's zero-based positions and converts rendered locations back. `findReferences` includes declarations so impact analysis does not omit the defining site. Provider, language id, workspace root, limits, timeout, initialization, and executable stay outside model input. -The tool requires the workspace root from the session `header.cwd`, with no fallback: absence fails as `LSP_WORKSPACE_REQUIRED` before querying. Locations render as stable, file-grouped `path:line:character` entries relativized against the result's `resolvedWorkspaceRoot` (the provider's canonical root), not the session cwd — so a symlinked cwd still renders in-workspace results as workspace-relative paths; a `file:` URI becomes a workspace-relative path (inside) or absolute path (outside), and any other URI stays verbatim. Empty locations and `null` hover are successful no-result responses; malformed provider payloads remain structured errors. +The tool requires the workspace root from the session `header.cwd`, with no fallback: absence fails as `LSP_WORKSPACE_REQUIRED` before querying. Its canonical result is the complete normalized seam union: `{ kind: "locations", locations, resolvedWorkspaceRoot }` or `{ kind: "hover", hover }`; Code Mode can inspect every acquired location and zero-based range directly. Native rendering then projects stable, file-grouped `path:line:character` entries relativized against the result's `resolvedWorkspaceRoot` (the provider's canonical root), not the session cwd — so a symlinked cwd still renders in-workspace results as workspace-relative paths; a `file:` URI becomes a workspace-relative path (inside) or absolute path (outside), and any other URI stays verbatim. Empty locations and `null` hover are successful no-result responses; malformed provider payloads remain structured errors. ## Configuration @@ -58,7 +58,7 @@ Prefix-stable while the visible tool definition and order are unchanged; registr #### What the model sees -File-grouped `path:line:character` location lines or normalized hover text, capped first by `maxLocations` and then by `maxResultChars`; omission and truncation markers are included inside the complete character cap. Empty results use distinct `No results.` / `No hover information.` lines. +File-grouped `path:line:character` location lines or normalized hover text, capped first by `maxLocations` and then by `maxResultChars`; omission and truncation markers are included inside the complete character cap. These caps affect only Native/model presentation, not the canonical value. Empty results use distinct `No results.` / `No hover information.` lines. #### Token effect diff --git a/packages/lsp/tool-lsp/src/index.ts b/packages/lsp/tool-lsp/src/index.ts index c298bdffb8..e47dbc87db 100644 --- a/packages/lsp/tool-lsp/src/index.ts +++ b/packages/lsp/tool-lsp/src/index.ts @@ -13,7 +13,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import { defineTool } from '@deepseek-ai/dsh-tools' -import { assertNever, type ContentBlock } from '@deepseek-ai/dsh-llm' +import { assertNever } from '@deepseek-ai/dsh-llm' import { LspError } from '@deepseek-ai/dsh-lsp' import type {} from '@deepseek-ai/dsh-lsp' import type {} from '@deepseek-ai/dsh-system-prompt' @@ -72,6 +72,24 @@ export const Config: z = z.object({ type ResolvedConfig = Required +const LSP_POSITION_OUTPUT_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { + line: { type: 'integer', required: true }, + character: { type: 'integer', required: true }, + }, +} as const + +const LSP_RANGE_OUTPUT_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { + start: { ...LSP_POSITION_OUTPUT_SCHEMA, required: true }, + end: { ...LSP_POSITION_OUTPUT_SCHEMA, required: true }, + }, +} as const + /** * Register the `lsp` tool and its system-prompt guidance. * @param ctx - the plugin context (must inject `tools`, `lsp`, `systemPrompt`). @@ -100,8 +118,66 @@ export function apply(ctx: Context, config: Config): void { line: { type: 'number', required: true, description: 'One-based line of the cursor.' }, character: { type: 'number', required: true, description: 'One-based UTF-16 column of the cursor.' }, }, + output: { + schema: { + oneOf: [ + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'locations' }, + locations: { + type: 'array', + required: true, + items: { + type: 'object', + additionalProperties: false, + properties: { + uri: { type: 'string', required: true }, + range: { ...LSP_RANGE_OUTPUT_SCHEMA, required: true }, + }, + }, + }, + resolvedWorkspaceRoot: { type: 'string', required: true }, + }, + }, + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'hover' }, + hover: { + required: true, + oneOf: [ + { type: 'null' }, + { + type: 'object', + additionalProperties: false, + properties: { + contents: { type: 'string', required: true }, + range: LSP_RANGE_OUTPUT_SCHEMA, + }, + }, + ], + }, + }, + }, + ], + }, + render: (_args, value) => { + switch (value.kind) { + case 'locations': + return [{ type: 'text', text: formatLocations(value.locations, value.resolvedWorkspaceRoot, resolved.maxLocations, resolved.maxResultChars) }] + case 'hover': + return [{ type: 'text', text: formatHover(value.hover, resolved.maxResultChars) }] + /* v8 ignore next -- exhaustive over the output schema's closed union; unreachable. */ + default: + return assertNever(value, 'tool-lsp output') + } + }, + }, timeoutMs: resolved.timeoutMs, - async execute(args, exec): Promise { + async execute(args, exec) { const input = parseLspArgs(args) const workspaceRoot = sessionCwd(exec) if (workspaceRoot === undefined) { @@ -115,12 +191,34 @@ export function apply(ctx: Context, config: Config): void { }, exec.signal) switch (result.kind) { case 'locations': - // Relativize against the provider's canonical workspace root (which its file: URIs are - // relative to), not the session cwd: a symlinked cwd would otherwise misclassify every - // in-workspace location as external and render it as an absolute path. - return [{ type: 'text', text: formatLocations(result.locations, result.resolvedWorkspaceRoot, resolved.maxLocations, resolved.maxResultChars) }] + return { + kind: 'locations' as const, + locations: result.locations.map(location => ({ + uri: location.uri, + range: { + start: { line: location.range.start.line, character: location.range.start.character }, + end: { line: location.range.end.line, character: location.range.end.character }, + }, + })), + resolvedWorkspaceRoot: result.resolvedWorkspaceRoot, + } case 'hover': - return [{ type: 'text', text: formatHover(result.hover, resolved.maxResultChars) }] + return { + kind: 'hover' as const, + hover: result.hover === null + ? null + : { + contents: result.hover.contents, + ...result.hover.range === undefined + ? {} + : { + range: { + start: { line: result.hover.range.start.line, character: result.hover.range.start.character }, + end: { line: result.hover.range.end.line, character: result.hover.range.end.character }, + }, + }, + }, + } /* v8 ignore next -- exhaustive over the closed LspQueryResult union; unreachable. */ default: return assertNever(result, 'tool-lsp result') diff --git a/packages/lsp/tool-lsp/tests/integration.spec.ts b/packages/lsp/tool-lsp/tests/integration.spec.ts index c6b8ce60f3..0a0265b490 100644 --- a/packages/lsp/tool-lsp/tests/integration.spec.ts +++ b/packages/lsp/tool-lsp/tests/integration.spec.ts @@ -90,7 +90,7 @@ describe('tool-lsp integration', () => { const ctx = await mount(true, 300) const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 7 }) expect(result.isError).toBe(true) - expect(result.error?.code).toBe('TOOL_TIMEOUT') + expect(result.error?.info?.code).toBe('TOOL_TIMEOUT') await ctx.fiber.dispose() }, 30_000) }) diff --git a/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts b/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts index b141fd00dd..de7a5319ef 100644 --- a/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts +++ b/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts @@ -126,6 +126,29 @@ describe('tool-lsp execution', () => { const { ctx } = await mount(stubProvider(() => okLocations)) const result = await call(ctx, { operation: 'findReferences', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot) expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' }) + expect(result).toMatchObject({ isError: false, value: okLocations }) + }) + + it('keeps all acquired locations in the canonical value when presentation is capped', async () => { + const cappedWorkspaceRoot = resolve('/virtual/capped-workspace') + const locations = [ + { uri: pathToFileURL(join(cappedWorkspaceRoot, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }, + { uri: pathToFileURL(join(cappedWorkspaceRoot, 'b.ts')).href, range: { start: { line: 1, character: 2 }, end: { line: 1, character: 3 } } }, + ] + const { ctx } = await mount(stubProvider(() => ({ + kind: 'locations', + locations, + resolvedWorkspaceRoot: cappedWorkspaceRoot, + })), { maxLocations: 1 }) + const result = await call(ctx, { operation: 'findReferences', file_path: 'a.ts', line: 1, character: 1 }, cappedWorkspaceRoot) + expect(result.content[0]).toEqual({ + type: 'text', + text: 'a.ts:1:1\n… 1 more location omitted (limit 1).', + }) + expect(result).toMatchObject({ + isError: false, + value: { kind: 'locations', locations, resolvedWorkspaceRoot: cappedWorkspaceRoot }, + }) }) it('relativizes against the provider resolvedWorkspaceRoot, not the session cwd', async () => { @@ -146,27 +169,42 @@ describe('tool-lsp execution', () => { const { ctx } = await mount(stubProvider(() => ({ kind: 'hover', hover: { contents: 'number' } }))) const result = await call(ctx, { operation: 'hover', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot) expect(result.content[0]).toEqual({ type: 'text', text: 'number' }) + expect(result).toMatchObject({ isError: false, value: { kind: 'hover', hover: { contents: 'number' } } }) + }) + + it('preserves an optional hover range in the canonical value', async () => { + const range = { start: { line: 2, character: 3 }, end: { line: 2, character: 7 } } + const { ctx } = await mount(stubProvider(() => ({ kind: 'hover', hover: { contents: 'number', range } }))) + const result = await call(ctx, { operation: 'hover', file_path: 'a.ts', line: 3, character: 4 }, '/ws') + expect(result).toMatchObject({ isError: false, value: { kind: 'hover', hover: { contents: 'number', range } } }) + }) + + it('preserves a null hover result as an explicit value', async () => { + const { ctx } = await mount(stubProvider(() => ({ kind: 'hover', hover: null }))) + const result = await call(ctx, { operation: 'hover', file_path: 'a.ts', line: 1, character: 1 }, '/ws') + expect(result.content[0]).toEqual({ type: 'text', text: 'No hover information.' }) + expect(result).toMatchObject({ isError: false, value: { kind: 'hover', hover: null } }) }) it('fails LSP_WORKSPACE_REQUIRED without a session cwd', async () => { const { ctx } = await mount(stubProvider(() => okLocations)) const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, null) expect(result.isError).toBe(true) - expect(result.error?.code).toBe('LSP_WORKSPACE_REQUIRED') + expect(result.error?.info?.code).toBe('LSP_WORKSPACE_REQUIRED') }) it('surfaces a structured LSP_UNAVAILABLE when no provider handles the file', async () => { const { ctx } = await mount(stubProvider(() => okLocations, { '.py': 'python' })) const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot) expect(result.isError).toBe(true) - expect(result.error?.code).toBe('LSP_UNAVAILABLE') + expect(result.error?.info?.code).toBe('LSP_UNAVAILABLE') }) it('returns a structured INVALID_ARGS on a bad operation', async () => { const { ctx } = await mount(stubProvider(() => okLocations)) const result = await call(ctx, { operation: 'rename', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot) expect(result.isError).toBe(true) - expect(result.error?.code).toBe('INVALID_ARGS') + expect(result.error?.info?.code).toBe('INVALID_ARGS') }) it('forwards exec.signal to the seam query', async () => { 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/package.json b/packages/mcp/mcp-client/package.json index cb3b51e1aa..3480b48e52 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -34,7 +34,8 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", - "schemastery": "^3.18.0" + "schemastery": "^3.18.0", + "zod": "^4.4.3" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", @@ -42,7 +43,6 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@modelcontextprotocol/server-everything": "^2026.7.4", "@modelcontextprotocol/server-filesystem": "^2026.7.4", - "cordis": "^4.0.0-rc.7", - "zod": "^4.4.3" + "cordis": "^4.0.0-rc.7" } } 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 92f3742547..49e023ca4d 100644 --- a/packages/mcp/mcp-client/src/tools.ts +++ b/packages/mcp/mcp-client/src/tools.ts @@ -14,8 +14,12 @@ import { createHash } from 'node:crypto' import type { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js' +import { z } from 'zod' 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 +30,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. @@ -38,6 +48,35 @@ const INVALID_NAME_CHARS = /[^A-Za-z0-9_-]/g /** Hex chars of the SHA-256 identity hash appended on lossy normalization. */ const HASH_LENGTH = 12 +/** Raw result record: the bridge owns JSON-value validation after transport. */ +const RawCallToolResultSchema = z.record(z.string(), z.unknown()) + +/** List without mutating the SDK's per-page output-validator cache. */ +function listToolsUncached(client: Client, cursor?: string) { + return client.request( + { method: 'tools/list', ...cursor === undefined ? {} : { params: { cursor } } }, + ListToolsResultSchema, + ) +} + +/** Call without the SDK pre-validating an output schema the bridge may not support. */ +function callToolUncached( + client: Client, + rawName: string, + args: Record, + exec: ToolExecution, + opts: ToolBridgeOptions, +) { + return client.request( + { method: 'tools/call', params: { name: rawName, arguments: args } }, + RawCallToolResultSchema, + { + signal: exec.signal, + timeout: opts.toolCallTimeoutMs, + }, + ) +} + /** * Derive the model-facing public name for one MCP tool. * @@ -65,7 +104,7 @@ export function publicToolName(serverName: string, rawName: string): string { * * Two phases keep the swap safe: * - * 1. Fetch: drain `client.listTools()` pagination and build the full next + * 1. Fetch: drain uncached `tools/list` pagination and build the full next * generation of `ToolDefinition`s under public names. Any failure here * (network error, duplicate raw name in the server's list) rejects and * leaves the previous generation registered untouched. @@ -93,7 +132,7 @@ export async function syncTools( const definitions = new Map() let cursor: string | undefined do { - const response = await client.listTools(cursor ? { cursor } : undefined) + const response = await listToolsUncached(client, cursor) for (const tool of response.tools) { const publicName = publicToolName(opts.serverName, tool.name) if (definitions.has(publicName)) { @@ -105,7 +144,8 @@ export async function syncTools( name: publicName, description: tool.description ?? '', parameters: tool.inputSchema, - execute: createExecutor(client, tool.name, opts), + output: createOutput(tool.name, supportedOutputSchema(tool.outputSchema)), + execute: createExecutor(client, tool.name, tool.execution?.taskSupport === 'required', opts), }) } cursor = response.nextCursor @@ -141,11 +181,42 @@ 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: structuredSchema === undefined ? ['content'] : ['content', 'structuredContent'], + 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 - * name), with abort signal and timeout, then maps the result to harness - * ContentBlocks. + * raw MCP tool name and sends an uncached `tools/call` request with it (never + * the public name), with abort signal and timeout, then maps the result to + * harness ContentBlocks. Owning the raw request prevents the SDK's internal + * per-page schema cache from pre-validating a different contract. * * When the MCP server returns `isError: true`, the executor throws so that * the ToolRegistry's catch path produces an `isError` result for the model. @@ -153,45 +224,53 @@ interface McpContentBlock { function createExecutor( client: Client, rawName: string, + taskRequired: boolean, opts: ToolBridgeOptions, ): ToolDefinition['execute'] { return async (args: unknown, exec: ToolExecution) => { + if (taskRequired) { + throw new Error(`Tool "${rawName}" requires task-based execution, which this bridge does not support`) + } // The agent loop passes `JSON.parse(model_arguments)` which is usually an // object, but can be any JSON value if the model misbehaves (outputs a bare // string/number/null). Fallback to {} lets the MCP server produce a // specific "missing required param" error the model can learn from. const argsObj = (typeof args === 'object' && args !== null ? args : {}) as Record - const result = await client.callTool( - { name: rawName, arguments: argsObj }, - undefined, - { - signal: exec.signal, - timeout: opts.toolCallTimeoutMs, - }, - ) + const result = await callToolUncached(client, rawName, argsObj, exec, opts) // 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 + if (!Array.isArray(result.content)) { + 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 (result.isError === true) throw new Error(text) + return { + content: [{ type: 'text', text }], + ...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. - if ('isError' in result && result.isError === true) { + if (result.isError === true) { throw new Error(text) } - return [{ type: 'text', text }] + return { + content, + ...result.structuredContent !== undefined + ? { structuredContent: result.structuredContent as JsonValue } + : {}, + } } } @@ -203,10 +282,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/apply.spec.ts b/packages/mcp/mcp-client/tests/apply.spec.ts index 4b43411346..e36e091478 100644 --- a/packages/mcp/mcp-client/tests/apply.spec.ts +++ b/packages/mcp/mcp-client/tests/apply.spec.ts @@ -15,14 +15,26 @@ import type { Config } from '@deepseek-ai/dsh-mcp-client' const { mockConnect, mockClose, mockListTools, mockCallTool, mockSetNotificationHandler, MockClient } = vi.hoisted(() => { const mockConnect = vi.fn<() => Promise>() const mockClose = vi.fn<() => Promise>() - const mockListTools = vi.fn() - const mockCallTool = vi.fn() + const mockListTools = vi.fn<(_params?: Record) => Promise>() + const mockCallTool = vi.fn<( + _params?: Record, _compatibilitySchema?: unknown, _options?: unknown, + ) => Promise>() const mockSetNotificationHandler = vi.fn() + const mockRequest = vi.fn(async ( + request: { method: string; params?: Record }, + _schema: unknown, + options?: unknown, + ): Promise => { + if (request.method === 'tools/list') return await mockListTools(request.params) + if (request.method === 'tools/call') return await mockCallTool(request.params, undefined, options) + throw new Error(`unexpected MCP request: ${request.method}`) + }) class MockClient { connect = mockConnect close = mockClose listTools = mockListTools callTool = mockCallTool + request = mockRequest setNotificationHandler = mockSetNotificationHandler } return { mockConnect, mockClose, mockListTools, mockCallTool, mockSetNotificationHandler, MockClient } diff --git a/packages/mcp/mcp-client/tests/mcp-client.spec.ts b/packages/mcp/mcp-client/tests/mcp-client.spec.ts index 557379808c..3077d6b86b 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.spec.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.spec.ts @@ -1,8 +1,10 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' 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' @@ -15,17 +17,37 @@ interface MockTool { name: string description?: string inputSchema: Record + outputSchema?: Record + execution?: { taskSupport?: 'optional' | 'required' | 'forbidden' } } interface MockCallResult { - content: Array<{ type: string; text?: string; mimeType?: string }> + content: JsonValue[] + structuredContent?: JsonValue isError?: boolean } function createMockClient(tools: MockTool[], callResult: MockCallResult = { content: [{ type: 'text', text: 'ok' }] }) { + const listTools = vi.fn(async ( + _params?: Record, + ): Promise<{ tools: MockTool[]; nextCursor: string | undefined }> => ({ tools, nextCursor: undefined })) + const callTool = vi.fn(async ( + _params?: Record, + _compatibilitySchema?: unknown, + _options?: unknown, + ): Promise> => ({ ...callResult })) return { - listTools: vi.fn().mockResolvedValue({ tools, nextCursor: undefined }), - callTool: vi.fn().mockResolvedValue(callResult), + listTools, + callTool, + request: vi.fn(async ( + request: { method: string; params?: Record }, + _schema: unknown, + options?: unknown, + ): Promise => { + if (request.method === 'tools/list') return listTools(request.params) + if (request.method === 'tools/call') return callTool(request.params, undefined, options) + throw new Error(`unexpected MCP request: ${request.method}`) + }), setNotificationHandler: vi.fn(), connect: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), @@ -116,7 +138,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' } }]) @@ -158,7 +181,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' } }, @@ -203,6 +227,82 @@ describe('syncTools', () => { expect(ctx.tools.get('mcp__srv__page1')).toBeDefined() expect(ctx.tools.get('mcp__srv__page2')).toBeDefined() }) + + it('owns output validation independently of the SDK per-page cache', async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + serverTransport.onmessage = (message) => { + if (!('id' in message) || !('method' in message)) return + const params = 'params' in message ? message.params : undefined + let result: Record + if (message.method === 'initialize') { + const protocolVersion = params && 'protocolVersion' in params + ? params.protocolVersion + : '2025-11-25' + result = { + protocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: 'raw-test', version: '1' }, + } + } else if (message.method === 'tools/list') { + const cursor = params && 'cursor' in params ? params.cursor : undefined + result = cursor === undefined + ? { + tools: [{ + name: 'supported', + inputSchema: { type: 'object' }, + outputSchema: { + type: 'object', + additionalProperties: false, + properties: { answer: { type: 'integer' } }, + required: ['answer'], + }, + }], + nextCursor: 'page-2', + } + : { + tools: [{ + name: 'future-schema', + inputSchema: { type: 'object' }, + outputSchema: { type: 'object', patternProperties: { '^x-': { type: 'string' } } }, + }], + } + } else if (message.method === 'tools/call') { + const name = params && 'name' in params ? params.name : undefined + result = name === 'supported' + ? { content: [{ type: 'text', text: 'missing structured content' }] } + : { content: [42, null], structuredContent: ['kept', { nested: true }] } + } else { + result = {} + } + void serverTransport.send({ jsonrpc: '2.0', id: message.id, result }) + } + await serverTransport.start() + const client = new Client({ name: 'cache-independent-test', version: '1' }) + await client.connect(clientTransport) + + try { + await syncTools(client, ctx, defaultOpts, new Map()) + + const missing = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('missing'), name: 'mcp__srv__supported', arguments: {}, + }) + expect(missing.error).toMatchObject({ info: { code: 'INVALID_TOOL_OUTPUT' } }) + expect(missing.error?.message).toContain('structuredContent') + + const fallback = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('fallback'), name: 'mcp__srv__future-schema', arguments: {}, + }) + if (fallback.isError) throw new Error('unsupported schema must use the bridge fallback') + expect(fallback.value).toEqual({ + content: [42, null], + structuredContent: ['kept', { nested: true }], + }) + } finally { + await client.close() + } + }) }) describe('tool execution', () => { @@ -223,6 +323,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' } }, @@ -261,16 +363,86 @@ 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({ signal: testToolSignal, 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({ + signal: testToolSignal, + 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({ signal: testToolSignal, 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({ signal: testToolSignal, 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({ signal: testToolSignal, 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 () => { @@ -284,6 +456,23 @@ 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('rejects tools that require task-based execution', async () => { + const client = createMockClient([ + { name: 'task-only', inputSchema: { type: 'object' }, execution: { taskSupport: 'required' } }, + ]) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('task-only'), name: 'mcp__srv__task-only', arguments: {}, + }) + + expect(result.isError).toBe(true) + expect(result.error?.message).toContain('requires task-based execution') + expect(client.callTool).not.toHaveBeenCalled() }) it('passes abort signal to callTool', async () => { @@ -315,6 +504,40 @@ 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({ + signal: testToolSignal, + 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({ + signal: testToolSignal, + 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', () => { @@ -425,7 +648,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({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__legacy2', arguments: {} }) @@ -433,6 +656,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({ signal: testToolSignal, 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/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index 1c43c37b7f..c72660637c 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -71,7 +71,7 @@ The user block is append-only conversation growth, while entering plan mode also #### What the model sees -The [`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) remains available in both states; execution outside plan mode fails, while an approved in-mode review returns the exit result and rejection returns feedback. +The [`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) remains available in both states; execution outside plan mode fails, while an approved in-mode review returns the canonical `{ approved: true }` value and renders the existing confirmation text. Rejection remains a failed call carrying review feedback. #### Token effect diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index 27d54c5a69..b705681a70 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -227,6 +227,16 @@ export class PlanModeService extends Service { parameters: { plan: { type: 'string', required: true, description: 'The complete plan, as markdown, starting with a # heading that names it.' }, }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + approved: { type: 'boolean', const: true, required: true }, + }, + }, + render: () => [{ type: 'text', text: 'Plan approved — plan mode exited; carry out the plan starting with your next step.' }], + }, execute: async (args, exec) => { const agent = exec.agent if (agent === undefined) throw new Error(`${EXIT_PLAN_MODE} requires a calling agent (no session to switch)`) @@ -270,7 +280,7 @@ export class PlanModeService extends Service { // Keep plan guidance for the rest of this assistant tool batch. The // silent intent flushes after the step, before the next assembly. this.pendingIntents.set(agent.session, { active: false, narrate: false }) - return [{ type: 'text', text: 'Plan approved — plan mode exited; carry out the plan starting with your next step.' }] + return { approved: true } }, presentCall: args => ({ card: 'generic', diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts index f1c57b5938..940b78870a 100644 --- a/packages/plan/plan-mode/tests/integration.spec.ts +++ b/packages/plan/plan-mode/tests/integration.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import LlmService, { type StreamChunk } 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, { 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 PlanModeService, { foldPlanMode } from '@deepseek-ai/dsh-plan-mode' @@ -30,7 +30,7 @@ async function harness(adapter: MockAdapter): Promise { await ctx.plugin(PlanModeService, PLAN_CONFIG) ctx.llm.registerAdapter(['mock'], adapter) for (const name of ['read', 'write']) { - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name, description: `test tool ${name}`, parameters: {}, diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index b5dc9723de..33199f1f94 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { RUN_CODE_NAME, defineContentToolFixture } from '@deepseek-ai/dsh-tools' import { Session, SessionId } from '@deepseek-ai/dsh-session' import { agentEvents, type Agent, type RequestErrorDecision } from '@deepseek-ai/dsh-agent' import { createScope } from '@deepseek-ai/dsh-scope' @@ -101,7 +101,7 @@ function noticeTexts(session: Session): string[] { function registerNamedTools(ctx: Context, names: string[]): void { for (const name of names) { - ctx.tools.register(defineTool({ + ctx.tools.register(defineContentToolFixture({ name, description: `test tool ${name}`, parameters: {}, @@ -110,6 +110,16 @@ function registerNamedTools(ctx: Context, names: string[]): void { } } +/** Assert the mapped Code Mode SDK includes the stable plan exit binding and test tools. */ +function expectPlanCodeSdkBindings(sdk: string): void { + expect(sdk).toContain('interface ToolArgsMap {') + expect(sdk).toContain('read: Record;') + expect(sdk).toContain('write: Record;') + expect(sdk).toContain('interface ToolOutputMap {') + expect(sdk).toContain('exit_plan_mode: {\n approved: true;\n };') + expect(sdk).toContain('[K in ToolName]: (args: ToolArgsMap[K]) => Promise;') +} + let callCounter = 0 function execute(ctx: Context, name: string, agent?: Agent) { return ctx.tools.execute({ @@ -445,9 +455,7 @@ describe('the soft layer', () => { // The SDK documents the full binding set plus the exit; plan mode never // prunes capabilities and restrains through guidance alone. const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text ?? '' - expect(sdk).toContain('read(args:') - expect(sdk).toContain('write(args:') - expect(sdk).toContain('exit_plan_mode(args:') + expectPlanCodeSdkBindings(sdk) }) it('keeps native wire schemas and the SDK in step under mode both', async () => { @@ -468,9 +476,7 @@ describe('the soft layer', () => { // is present on the wire AND in the SDK alongside the untouched toolset. expect(assembly.tools.map(tool => tool.name).sort()).toEqual(['exit_plan_mode', 'read', 'run_code', 'write']) const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text ?? '' - expect(sdk).toContain('read(args:') - expect(sdk).toContain('write(args:') - expect(sdk).toContain('exit_plan_mode(args:') + expectPlanCodeSdkBindings(sdk) }) it('keeps the Code Mode SDK byte-identical across mode switches', async () => { @@ -487,9 +493,7 @@ describe('the soft layer', () => { registerNamedTools(withPlanMode, ['read', 'write']) const agent = await agentWithSession(withPlanMode) const defaultSdk = (await assembleFor(withPlanMode, agent)).sections.find(section => section.name === 'tools:sdk')?.text ?? '' - expect(defaultSdk).toContain('read(args:') - expect(defaultSdk).toContain('write(args:') - expect(defaultSdk).toContain('exit_plan_mode(args:') + expectPlanCodeSdkBindings(defaultSdk) agent.session.append('plan/mode', { active: true }) const planSdk = (await assembleFor(withPlanMode, agent)).sections.find(section => section.name === 'tools:sdk')?.text ?? '' expect(planSdk).toBe(defaultSdk) @@ -502,7 +506,7 @@ describe('the soft layer', () => { await bare.plugin(FakeRuntime) registerNamedTools(bare, ['read', 'write']) const bareSdk = (await bare.systemPrompt.assemble({ agent })).sections.find(section => section.name === 'tools:sdk')?.text ?? '' - expect(bareSdk).not.toContain('exit_plan_mode(args:') + expect(bareSdk).not.toContain('exit_plan_mode:') expect(defaultSdk).not.toBe(bareSdk) }) }) @@ -662,6 +666,8 @@ describe('exit_plan_mode', () => { const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] }) const result = await callExit(ctx, agent) expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected approved plan result') + expect(result.value).toEqual({ approved: true }) expect(result.content).toEqual([{ type: 'text', text: 'Plan approved — plan mode exited; carry out the plan starting with your next step.' }]) // Boundary-applied, not a direct append: the fold stays plan until the // step's end, so the plan policy covers any remaining call of the SAME batch. diff --git a/packages/pty/tool-pty/README.md b/packages/pty/tool-pty/README.md index 5a0edca33a..de4633fed6 100644 --- a/packages/pty/tool-pty/README.md +++ b/packages/pty/tool-pty/README.md @@ -44,7 +44,7 @@ Prefix-stable while tool visibility and definitions are unchanged. #### What the model sees -Spawn returns the id and bounded MOTD. Send/read return bounded terminal text plus readiness/history markers. Background mode returns a generic task id. Results remain in session history until compaction; incremental task reads do not repeat consumed output. +Spawn returns the id and bounded MOTD. Send/read return bounded terminal text plus readiness/history markers. Background mode returns a generic task id. Results remain in session history until compaction; incremental task reads do not repeat consumed output. Programmatic callers receive typed session snapshots, bounded send/read DTOs, signal and close outcomes, or `{ kind: "background", taskId }`; Native rendering preserves the text above. #### Token effect diff --git a/packages/pty/tool-pty/src/index.ts b/packages/pty/tool-pty/src/index.ts index f145102dea..9aedd4c649 100644 --- a/packages/pty/tool-pty/src/index.ts +++ b/packages/pty/tool-pty/src/index.ts @@ -6,12 +6,11 @@ import { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { PtySessionId } from '@deepseek-ai/dsh-pty' import type { PtySendResult, PtySessionId as PtySessionIdType, PtySignal } from '@deepseek-ai/dsh-pty' import type {} from '@deepseek-ai/dsh-tasks' import { defineTool } from '@deepseek-ai/dsh-tools' -import type { ToolExecutionResult, ToolResult } from '@deepseek-ai/dsh-tools' +import type { ToolResult } from '@deepseek-ai/dsh-tools' import { renderList, renderRead, renderSend, renderSendRead, renderSpawn } from './render.ts' declare module '@deepseek-ai/dsh-tasks' { @@ -50,6 +49,50 @@ interface SignalArgs extends SessionArgs { signal: PtySignal } +const SESSION_STATUS_SCHEMA = { + oneOf: [ + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'running' }, + }, + }, + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'exited' }, + exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] }, + signal: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] }, + }, + }, + ], +} as const + +const SESSION_SNAPSHOT_PROPERTIES = { + sessionId: { type: 'string', required: true }, + name: { type: 'string' }, + type: { type: 'string', required: true }, + pid: { type: 'integer' }, + status: { ...SESSION_STATUS_SCHEMA, required: true }, +} as const + +const SESSION_SNAPSHOT_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: SESSION_SNAPSHOT_PROPERTIES, +} as const + +const BACKGROUND_TASK_OUTPUT_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'background' }, + taskId: { type: 'string', required: true }, + }, +} as const + function requireAgent(agent: Agent | undefined): Agent { if (agent === undefined) throw new Error('terminal tools require an initiating agent') return agent @@ -62,10 +105,6 @@ function sessionId(args: SessionArgs): PtySessionIdType { return PtySessionId(args.sessionId) } -function textResult(text: string): ContentBlock[] { - return [{ type: 'text', text }] -} - function rawResultText(result: ToolResult): string | undefined { if (result.content.length !== 1) return undefined const block = result.content[0] @@ -94,6 +133,17 @@ export function apply(ctx: Context): void { name: { type: 'string', description: 'Optional owner-local display name such as "main" or "gdb".' }, cwd: { type: 'string', description: 'Initial working directory. Defaults to the deployment workspace root.' }, }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + ...SESSION_SNAPSHOT_PROPERTIES, + motd: { type: 'string', required: true }, + }, + }, + render: (_args, value) => [{ type: 'text', text: renderSpawn(value) }], + }, async execute(args: SpawnArgs, exec) { if (args.type.length === 0) throw new Error('type must be a non-empty string') const result = await ctx.pty.spawn(requireAgent(exec.agent), { @@ -101,7 +151,7 @@ export function apply(ctx: Context): void { ...args.name !== undefined ? { name: args.name } : {}, ...args.cwd !== undefined ? { cwd: args.cwd } : {}, }, exec.signal) - return textResult(renderSpawn(result)) + return result }, presentCall: (args) => { const parsed = args @@ -118,7 +168,43 @@ export function apply(ctx: Context): void { submit: { type: 'boolean', description: 'Submit Enter after text (default true). Set false for control characters or incomplete REPL input.' }, run_in_background: { type: 'boolean', description: 'Return a task id immediately; collect with task_output or stop with task_kill.' }, }, - async execute(args: SendArgs, exec): Promise { + output: { + schema: { + oneOf: [ + BACKGROUND_TASK_OUTPUT_SCHEMA, + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'foreground' }, + viewport: { type: 'string', required: true }, + waitReason: { + type: 'string', + required: true, + enum: ['stdin_read', 'inferred_idle', 'timeout', 'session_exit'], + }, + sessionStatus: { ...SESSION_STATUS_SCHEMA, required: true }, + truncated: { type: 'boolean', required: true }, + }, + }, + ], + }, + render: (_args, value) => [{ + type: 'text', + text: value.kind === 'background' + ? `started background task ${value.taskId}` + : renderSend(value), + }], + presentationMeta: (_args, value) => value.kind === 'foreground' + ? { + viewport: value.viewport, + waitReason: value.waitReason, + sessionStatus: value.sessionStatus, + truncated: value.truncated, + } + : null, + }, + async execute(args: SendArgs, exec) { const owner = requireAgent(exec.agent) const id = sessionId(args) const request = { text: args.text, submit: args.submit ?? true } @@ -145,12 +231,12 @@ export function apply(ctx: Context): void { } }, }) - return { content: textResult(`started background task ${taskId}`), isError: false } + return { kind: 'background' as const, taskId } } const operation = ctx.pty.startSend(owner, id, { ...request, signal: exec.signal }) const result = await operation.done if (exec.signal.aborted) throw new Error('terminal send aborted') - return { content: textResult(renderSend(result)), isError: false, meta: result } + return { kind: 'foreground' as const, ...result } }, presentCall(args) { const parsed = args as Partial @@ -174,12 +260,26 @@ export function apply(ctx: Context): void { offset: { type: 'number', description: 'Newest-relative line offset (default 0).' }, count: { type: 'number', description: 'Requested line count (default 500; backend caps apply).' }, }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + text: { type: 'string', required: true }, + totalLines: { type: 'integer', required: true }, + lineBegin: { type: 'integer', required: true }, + lineEnd: { type: 'integer', required: true }, + truncated: { type: 'boolean', required: true }, + }, + }, + render: (_args, value) => [{ type: 'text', text: renderRead(value) }], + }, execute(args: ReadArgs, exec) { const result = ctx.pty.read(requireAgent(exec.agent), sessionId(args), { ...args.offset !== undefined ? { offset: args.offset } : {}, ...args.count !== undefined ? { count: args.count } : {}, }) - return Promise.resolve(textResult(renderRead(result))) + return Promise.resolve(result) }, presentCall: args => ({ card: 'generic', title: `Read terminal ${(args).sessionId}`, kind: 'read', rawInput: args }), })) @@ -191,11 +291,21 @@ export function apply(ctx: Context): void { sessionId: { type: 'string', required: true, description: 'Terminal session id.' }, signal: { type: 'string', required: true, enum: ['SIGINT', 'SIGTERM', 'SIGKILL', 'SIGTSTP', 'SIGHUP'], description: 'Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.' }, }, - async execute(args: SignalArgs, exec) { - const result = await ctx.pty.signal(requireAgent(exec.agent), sessionId(args), args.signal) - return textResult(`delivered ${args.signal} to foreground process group ${result.targetPgid}`) + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + delivered: { type: 'boolean', required: true, const: true }, + targetPgid: { type: 'integer', required: true }, + }, + }, + render: (args, value) => [{ type: 'text', text: `delivered ${args.signal} to foreground process group ${value.targetPgid}` }], }, - presentCall: args => ({ card: 'generic', title: `Signal terminal ${(args as SignalArgs).sessionId}`, kind: 'execute', rawInput: args }), + async execute(args: SignalArgs, exec) { + return ctx.pty.signal(requireAgent(exec.agent), sessionId(args), args.signal) + }, + presentCall: args => ({ card: 'generic', title: `Signal terminal ${args.sessionId}`, kind: 'execute', rawInput: args }), })) ctx.tools.register(defineTool({ @@ -204,10 +314,26 @@ export function apply(ctx: Context): void { parameters: { sessionId: { type: 'string', required: true, description: 'Terminal session id.' }, }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + sessionId: { type: 'string', required: true }, + outcome: { type: 'string', required: true, enum: ['closed', 'already-closing'] }, + }, + }, + render: (_args, value) => [{ + type: 'text', + text: value.outcome === 'closed' + ? `closed terminal session ${value.sessionId}` + : `terminal session ${value.sessionId} was already closing`, + }], + }, async execute(args: SessionArgs, exec) { const id = sessionId(args) const closed = await ctx.pty.kill(requireAgent(exec.agent), id) - return textResult(closed ? `closed terminal session ${id}` : `terminal session ${id} was already closing`) + return { sessionId: id, outcome: closed ? 'closed' as const : 'already-closing' as const } }, presentCall: args => ({ card: 'generic', title: `Close terminal ${(args).sessionId}`, kind: 'delete' }), })) @@ -216,8 +342,12 @@ export function apply(ctx: Context): void { name: 'terminal_list', description: 'List persistent terminal sessions owned by the current agent.', parameters: {}, + output: { + schema: { type: 'array', items: SESSION_SNAPSHOT_SCHEMA }, + render: (_args, value) => [{ type: 'text', text: renderList(value) }], + }, execute(_args: Record, exec) { - return Promise.resolve(textResult(renderList(ctx.pty.list(requireAgent(exec.agent))))) + return Promise.resolve(ctx.pty.list(requireAgent(exec.agent))) }, presentCall: () => ({ card: 'generic', title: 'List terminal sessions', kind: 'read' }), })) diff --git a/packages/pty/tool-pty/src/render.ts b/packages/pty/tool-pty/src/render.ts index bed176e890..ea1f31bbe0 100644 --- a/packages/pty/tool-pty/src/render.ts +++ b/packages/pty/tool-pty/src/render.ts @@ -1,13 +1,55 @@ /** Model and ACP rendering for persistent terminal tool results. */ -import type { PtyReadResult, PtySendRead, PtySendResult, PtySessionSnapshot, PtySpawnResult } from '@deepseek-ai/dsh-pty' +interface RenderedSessionStatusRunning { + kind: 'running' +} + +interface RenderedSessionStatusExited { + kind: 'exited' + exitCode: number | null + signal: string | null +} + +type RenderedSessionStatus = RenderedSessionStatusRunning | RenderedSessionStatusExited + +interface RenderedSessionSnapshot { + sessionId: string + name?: string + type: string + pid?: number + status: RenderedSessionStatus +} + +interface RenderedSpawnResult extends RenderedSessionSnapshot { + motd: string +} + +interface RenderedSendResult { + viewport: string + waitReason: 'stdin_read' | 'inferred_idle' | 'timeout' | 'session_exit' + sessionStatus: RenderedSessionStatus + truncated: boolean +} + +interface RenderedSendRead { + delta: string + truncated: boolean +} + +interface RenderedReadResult { + text: string + totalLines: number + lineBegin: number + lineEnd: number + truncated: boolean +} /** * Render one created session and its bounded MOTD. * @param result - published spawn result. * @returns Model-facing session acknowledgement. */ -export function renderSpawn(result: PtySpawnResult): string { +export function renderSpawn(result: RenderedSpawnResult): string { const label = result.name === undefined ? result.sessionId : `${result.sessionId} (${result.name})` return `started terminal session ${label} [type: ${result.type}]\n${result.motd || '(no startup output)'}` } @@ -17,7 +59,7 @@ export function renderSpawn(result: PtySpawnResult): string { * @param result - settled send outcome. * @returns Terminal output plus wait/session markers. */ -export function renderSend(result: PtySendResult): string { +export function renderSend(result: RenderedSendResult): string { const output = result.viewport || '(no new output)' const status = result.sessionStatus.kind === 'running' ? 'running' @@ -30,7 +72,7 @@ export function renderSend(result: PtySendResult): string { * @param read - consuming operation delta. * @returns Delta plus truncation marker when needed. */ -export function renderSendRead(read: PtySendRead): string { +export function renderSendRead(read: RenderedSendRead): string { return `${read.delta}${read.truncated ? `${read.delta.endsWith('\n') || read.delta.length === 0 ? '' : '\n'}[output truncated]` : ''}` } @@ -39,7 +81,7 @@ export function renderSendRead(read: PtySendRead): string { * @param result - retained scrollback page. * @returns Page text plus pagination and truncation markers. */ -export function renderRead(result: PtyReadResult): string { +export function renderRead(result: RenderedReadResult): string { const output = result.text || '(no retained output)' return `${output}\n[lines: ${result.lineBegin}-${result.lineEnd} of ${result.totalLines}]${result.truncated ? '\n[output truncated]' : ''}` } @@ -49,7 +91,7 @@ export function renderRead(result: PtyReadResult): string { * @param sessions - fresh owner-scoped snapshots. * @returns One line per session or the empty marker. */ -export function renderList(sessions: PtySessionSnapshot[]): string { +export function renderList(sessions: readonly RenderedSessionSnapshot[]): string { if (sessions.length === 0) return '(no terminal sessions)' return sessions.map((session) => { const name = session.name === undefined ? '' : ` (${session.name})` diff --git a/packages/pty/tool-pty/tests/tools.spec.ts b/packages/pty/tool-pty/tests/tools.spec.ts index 5adcaea441..34bc29d14c 100644 --- a/packages/pty/tool-pty/tests/tools.spec.ts +++ b/packages/pty/tool-pty/tests/tools.spec.ts @@ -5,7 +5,8 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' +import ToolRegistry, { renderToolsSdk } from '@deepseek-ai/dsh-tools' +import type { ToolSdkSchema } from '@deepseek-ai/dsh-tools/src/ts-types.ts' import PtyService, { PtySessionId } from '@deepseek-ai/dsh-pty' import type { PtyBackend, PtyBackendSession, PtySendOperation, PtySendRequest, PtySessionStatus, PtySignal } from '@deepseek-ai/dsh-pty' import TaskService from '@deepseek-ai/dsh-tasks' @@ -104,6 +105,7 @@ async function setup(tasks: boolean) { } let callNumber = 0 +const TOOL_NAMES = ['terminal_open', 'terminal_send', 'terminal_read', 'terminal_signal', 'terminal_close', 'terminal_list'] as const const testToolSignal = new AbortController().signal function call(ctx: Context, name: string, args: unknown, agent?: Agent) { return ctx.tools.execute({ signal: testToolSignal, callId: CallId(`pty-call-${++callNumber}`), name, arguments: args, ...agent ? { agent } : {} }) @@ -120,17 +122,134 @@ function text(result: { content: { type: string; text?: string }[] }): string { describe('tool-pty foreground surface', () => { it('registers exactly six schemas and drives the full owner-scoped lifecycle', async () => { const { ctx, agent } = await setup(false) - expect(['terminal_open', 'terminal_send', 'terminal_read', 'terminal_signal', 'terminal_close', 'terminal_list'].every(name => ctx.tools.get(name) !== undefined)).toBe(true) + expect(TOOL_NAMES.every(name => ctx.tools.get(name) !== undefined)).toBe(true) const spawned = await call(ctx, 'terminal_open', { type: 'stub', name: 'main' }, agent) expect(text(spawned)).toContain('started terminal session pty-1 (main)') - expect(text(await call(ctx, 'terminal_list', {}, agent))).toContain('pty-1 (main) [stub] running pid=42') - expect(text(await call(ctx, 'terminal_read', { sessionId: 'pty-1' }, agent))).toContain('history\n[lines: 0-1 of 1]') - expect(text(await call(ctx, 'terminal_signal', { sessionId: 'pty-1', signal: 'SIGINT' }, agent))).toBe('delivered SIGINT to foreground process group 10') + expect(spawned).toMatchObject({ + isError: false, + value: { + sessionId: 'pty-1', + name: 'main', + type: 'stub', + pid: 42, + status: { kind: 'running' }, + motd: 'stub prompt', + }, + }) + const listed = await call(ctx, 'terminal_list', {}, agent) + expect(text(listed)).toContain('pty-1 (main) [stub] running pid=42') + expect(listed).toMatchObject({ isError: false, value: [{ sessionId: 'pty-1', name: 'main', type: 'stub', pid: 42, status: { kind: 'running' } }] }) + const read = await call(ctx, 'terminal_read', { sessionId: 'pty-1' }, agent) + expect(text(read)).toContain('history\n[lines: 0-1 of 1]') + expect(read).toMatchObject({ isError: false, value: { text: 'history', totalLines: 1, lineBegin: 0, lineEnd: 1, truncated: false } }) + const signalled = await call(ctx, 'terminal_signal', { sessionId: 'pty-1', signal: 'SIGINT' }, agent) + expect(text(signalled)).toBe('delivered SIGINT to foreground process group 10') + expect(signalled).toMatchObject({ isError: false, value: { delivered: true, targetPgid: 10 } }) const sent = await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'echo hi' }, agent) expect(text(sent)).toContain('command output\n[wait: stdin_read]\n[session: running]') - expect(text(await call(ctx, 'terminal_close', { sessionId: 'pty-1' }, agent))).toBe('closed terminal session pty-1') - expect(text(await call(ctx, 'terminal_list', {}, agent))).toBe('(no terminal sessions)') + expect(sent).toMatchObject({ + isError: false, + value: { + kind: 'foreground', + viewport: 'command output', + waitReason: 'stdin_read', + sessionStatus: { kind: 'running' }, + truncated: false, + }, + meta: { + viewport: 'command output', + waitReason: 'stdin_read', + sessionStatus: { kind: 'running' }, + truncated: false, + }, + }) + const closed = await call(ctx, 'terminal_close', { sessionId: 'pty-1' }, agent) + expect(text(closed)).toBe('closed terminal session pty-1') + expect(closed).toMatchObject({ isError: false, value: { sessionId: 'pty-1', outcome: 'closed' } }) + const empty = await call(ctx, 'terminal_list', {}, agent) + expect(text(empty)).toBe('(no terminal sessions)') + expect(empty).toMatchObject({ isError: false, value: [] }) + }) + + it('projects every terminal DTO into the generated Code Mode output map', async () => { + const { ctx } = await setup(false) + const schemas = TOOL_NAMES.map((toolName): ToolSdkSchema => { + const definition = ctx.tools.get(toolName) + if (definition === undefined) throw new Error(`missing terminal tool ${toolName}`) + return { + name: definition.name, + description: definition.description, + parameters: definition.parameters, + output: definition.output.schema, + } + }) + const sdk = renderToolsSdk(schemas) + const outputMapStart = sdk.indexOf('interface ToolOutputMap') + const outputMapEnd = sdk.indexOf('\n\ntype ToolName', outputMapStart) + + expect(sdk.slice(outputMapStart, outputMapEnd)).toMatchInlineSnapshot(` + "interface ToolOutputMap { + terminal_close: { + sessionId: string; + outcome: "closed" | "already-closing"; + }; + terminal_list: ({ + sessionId: string; + name?: string; + type: string; + pid?: number; + status: { + kind: "running"; + } | { + kind: "exited"; + exitCode: number | null; + signal: string | null; + }; + })[]; + terminal_open: { + sessionId: string; + name?: string; + type: string; + pid?: number; + status: { + kind: "running"; + } | { + kind: "exited"; + exitCode: number | null; + signal: string | null; + }; + motd: string; + }; + terminal_read: { + text: string; + totalLines: number; + lineBegin: number; + lineEnd: number; + truncated: boolean; + }; + terminal_send: { + kind: "background"; + taskId: string; + } | { + kind: "foreground"; + viewport: string; + waitReason: "stdin_read" | "inferred_idle" | "timeout" | "session_exit"; + sessionStatus: { + kind: "running"; + } | { + kind: "exited"; + exitCode: number | null; + signal: string | null; + }; + truncated: boolean; + }; + terminal_signal: { + delivered: true; + targetPgid: number; + }; + }" + `) }) it('fails without an initiating agent and rejects background before writing', async () => { @@ -178,7 +297,9 @@ describe('tool-pty task integration', () => { it('registers a generic task and exposes incremental output', async () => { const { ctx, agent } = await setup(true) await call(ctx, 'terminal_open', { type: 'stub' }, agent) - expect(text(await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'build', run_in_background: true }, agent))).toBe('started background task pty-send-1') + const started = await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'build', run_in_background: true }, agent) + expect(text(started)).toBe('started background task pty-send-1') + expect(started).toMatchObject({ isError: false, value: { kind: 'background', taskId: 'pty-send-1' } }) const output = await call(ctx, 'task_output', { task_id: 'pty-send-1', wait: true }, agent) expect(text(output)).toContain('live output') expect(text(output)).toContain('[status: completed, wait: stdin_read]') @@ -224,7 +345,9 @@ describe('tool-pty task integration', () => { const second = call(ctx, 'terminal_close', { sessionId: 'pty-1' }, agent) stub.sessions[0]!.closeGate?.resolve(undefined) await first - expect(text(await second)).toBe('terminal session pty-1 was already closing') + const result = await second + expect(text(result)).toBe('terminal session pty-1 was already closing') + expect(result).toMatchObject({ isError: false, value: { sessionId: 'pty-1', outcome: 'already-closing' } }) }) it('renders an exited session detail for background completion', async () => { diff --git a/packages/session-persistence/session-checkpoint-policy/src/index.ts b/packages/session-persistence/session-checkpoint-policy/src/index.ts index 138e45db2d..0dcd2045a9 100644 --- a/packages/session-persistence/session-checkpoint-policy/src/index.ts +++ b/packages/session-persistence/session-checkpoint-policy/src/index.ts @@ -42,7 +42,10 @@ function abortedBeforeDispatchResult(): ToolExecutionResult { return { content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }], isError: true, - error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + error: { + message: 'tool call aborted before dispatch', + info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + }, } } diff --git a/packages/session-persistence/session-checkpoint-policy/tests/fixtures/crash-child.ts b/packages/session-persistence/session-checkpoint-policy/tests/fixtures/crash-child.ts index 17a9aec997..89cd254c8e 100644 --- a/packages/session-persistence/session-checkpoint-policy/tests/fixtures/crash-child.ts +++ b/packages/session-persistence/session-checkpoint-policy/tests/fixtures/crash-child.ts @@ -45,6 +45,7 @@ ctx.tools.register({ name: 'crash_tool', description: 'records an external effect and never returns', parameters: {}, + output: { schema: { type: 'null' }, render: () => [] }, async execute() { await writeFile(failpoint, 'tool-side-effect') return waitForCrash() diff --git a/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts b/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts index 2056d30729..7ba3fd09e6 100644 --- a/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts +++ b/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts @@ -121,7 +121,8 @@ describe('session-checkpoint-policy tool and step boundaries', () => { }) ctx.tools.register({ name: 'write', description: 'side effect', parameters: {}, - execute: async () => { order.push('tool'); return [] }, + output: { schema: { type: 'null' }, render: () => [] }, + execute: async () => { order.push('tool'); return null }, }) const pending = ctx.tools.execute({ @@ -149,7 +150,8 @@ describe('session-checkpoint-policy tool and step boundaries', () => { }) ctx.tools.register({ name: 'write', description: 'side effect', parameters: {}, - execute: async () => { order.push('tool'); return [] }, + output: { schema: { type: 'null' }, render: () => [] }, + execute: async () => { order.push('tool'); return null }, }) const pending = ctx.tools.execute({ @@ -164,7 +166,10 @@ describe('session-checkpoint-policy tool and step boundaries', () => { await expect(pending).resolves.toEqual({ content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }], isError: true, - error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + error: { + message: 'tool call aborted before dispatch', + info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + }, }) expect(order).toEqual(['flush:start', 'flush:end']) }) @@ -177,7 +182,8 @@ describe('session-checkpoint-policy tool and step boundaries', () => { ctx.on('session/flush', () => Promise.reject(new Error('disk unavailable'))) ctx.tools.register({ name: 'write', description: 'side effect', parameters: {}, - execute: async () => { ran = true; return [] }, + output: { schema: { type: 'null' }, render: () => [] }, + execute: async () => { ran = true; return null }, }) const result = await ctx.tools.execute({ callId: CallId('write-2'), name: 'write', arguments: {}, agent, @@ -194,7 +200,11 @@ describe('session-checkpoint-policy tool and step boundaries', () => { const agent = { session } as Agent let flushes = 0 ctx.on('session/flush', () => { flushes += 1 }) - ctx.tools.register({ name: 'nested', description: 'nested', parameters: {}, execute: async () => [] }) + ctx.tools.register({ + name: 'nested', description: 'nested', parameters: {}, + output: { schema: { type: 'null' }, render: () => [] }, + execute: async () => null, + }) await ctx.tools.execute({ callId: CallId('nested-1'), name: 'nested', arguments: {}, agent, parent: Symbol('outer') as never, 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 90d891c20e..7fef1cbf22 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' @@ -187,7 +187,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: {}, @@ -229,6 +229,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') @@ -286,7 +293,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({ @@ -301,9 +308,10 @@ describe('dsh-tool-skill', () => { const result = await ctx.tools.execute({ signal: testToolSignal, 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..cf46ccafd6 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. The generic policy prepends its waterfall listener, then delegates, so ordinary tool-owned asynchronous projections complete before generic byte bounding regardless of plugin load order. 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/package.json b/packages/spill/spill-policy/package.json index dac7d4311d..e8e9fb4631 100644 --- a/packages/spill/spill-policy/package.json +++ b/packages/spill/spill-policy/package.json @@ -40,6 +40,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-code-runtime-worker": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-retention": "workspace:^", diff --git a/packages/spill/spill-policy/src/index.ts b/packages/spill/spill-policy/src/index.ts index b7ac4a31fc..26c501257c 100644 --- a/packages/spill/spill-policy/src/index.ts +++ b/packages/spill/spill-policy/src/index.ts @@ -16,15 +16,21 @@ * - 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. + * It COMPOSES with other post-execute listeners: its prepended listener + * delegates via `next()` and bounds the resulting content projection, so + * tool-owned asynchronous projection runs before generic bounding, a hook that + * replaced content still has its replacement bounded, and value replacements + * and `block` decisions pass through unchanged. * * @module @deepseek-ai/dsh-spill-policy */ @@ -109,7 +115,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) @@ -170,5 +177,5 @@ export function apply(ctx: Context, config: Config): void { } const replaced: ContentBlock[] = [{ type: 'text', text: replacedText }] return { kind: 'accept', content: replaced, ...decision.additionalContexts ? { additionalContexts: decision.additionalContexts } : {} } - }) + }, { prepend: true }) } diff --git a/packages/spill/spill-policy/tests/spill-policy.spec.ts b/packages/spill/spill-policy/tests/spill-policy.spec.ts index 3342150580..364c4c49ae 100644 --- a/packages/spill/spill-policy/tests/spill-policy.spec.ts +++ b/packages/spill/spill-policy/tests/spill-policy.spec.ts @@ -15,11 +15,12 @@ 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 { PostToolDecision, 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' +import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker' const testToolSignal = new AbortController().signal @@ -41,7 +42,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: {}, @@ -60,7 +61,11 @@ function exec(name: string, session = 's1'): ToolExecution { * Build a context with tools + the policy, and optionally a spill backend. * Returns the context and the backend handle (undefined when `withSpill` false). */ -async function setup(config: SpillPolicy.Config, withSpill = true): Promise<{ ctx: Context; spill?: StubStore; fiber: Awaited> }> { +async function setup( + config: SpillPolicy.Config, + withSpill = true, + beforePolicy?: (ctx: Context) => void, +): Promise<{ ctx: Context; spill?: StubStore; fiber: Awaited> }> { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) @@ -69,6 +74,7 @@ async function setup(config: SpillPolicy.Config, withSpill = true): Promise<{ ct await ctx.plugin(StubStore) spill = ctx.spillStore as StubStore } + beforePolicy?.(ctx) const fiber = await ctx.plugin(SpillPolicy, config) return { ctx, fiber, ...spill ? { spill } : {} } } @@ -162,7 +168,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: {}, @@ -176,6 +182,43 @@ describe('oversized plain-text replacement', () => { }) }) +describe('outer Code Mode failure capture', () => { + it('spills the bounded output-limit diagnostic through the ordinary outer-result policy', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry, { mode: 'code' }) + await ctx.plugin(StubStore) + await ctx.plugin(SpillPolicy, { maxInlineBytes: 200 }) + await ctx.plugin(WorkerCodeRuntime, { maxOutputBytes: 500 }) + const events: unknown[] = [] + const agent = { + session: { + header: { id: SessionId('code-spill'), cwd: '/workspace' }, + append: (_type: string, data: unknown) => { events.push(data) }, + }, + } + + const result = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('code-output-limit'), + name: 'run_code', + arguments: { + code: 'console.log("HEAD-" + "x".repeat(300)); console.log("TAIL-" + "y".repeat(300)); return "unreachable";', + }, + agent: agent as never, + }) + + expect(result.isError).toBe(true) + const saved = (ctx.spillStore as StubStore).saves + expect(saved).toHaveLength(1) + expect(saved[0]?.source.toolName).toBe('run_code') + expect(saved[0]?.content).toContain('code run failed (output-limit)') + expect(saved[0]?.content).toContain('HEAD-') + expect(textOf(result.content)).toContain('Full formatted result stored at: /spill/run_code.txt') + expect(events).toEqual([]) + }) +}) + describe('read skip', () => { it('never spills the read tool result (avoids a read → spill → read loop)', async () => { const { ctx, spill } = await setup({ maxInlineBytes: 10 }) @@ -186,6 +229,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 }) @@ -219,6 +277,26 @@ describe('best-effort fallback', () => { }) describe('composition', () => { + it('wraps an earlier tool-owned projection before applying the generic cap', async () => { + let downstreamDecision: PostToolDecision | undefined + const { ctx, spill } = await setup({ maxInlineBytes: 200 }, true, (target) => { + target.on('tools/post-execute', async (_exec, _result, next): Promise => { + downstreamDecision = await next() + return { + kind: 'accept', + content: [{ type: 'text', text: `first page\n\nFull canonical result stored at /spill/search-results.txt.\n${'z'.repeat(500)}` }], + } + }) + }) + ctx.tools.register(textTool('search', 'initial capped page')) + + const result = await ctx.tools.execute(exec('search')) + + expect(downstreamDecision).toEqual({ kind: 'accept' }) + expect(spill?.saves[0]?.content).toContain('Full canonical result stored at /spill/search-results.txt.') + expect(textOf(result.content)).toContain('Full formatted result stored at') + }) + it('bounds content a downstream post-execute listener replaced', async () => { const { ctx, spill } = await setup({ maxInlineBytes: 200 }) // A later-registered listener replaces the (small) tool result with a big one; @@ -241,6 +319,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 e24df08af5..7776b2af3f 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 811d754094..522d36e2d9 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -12,9 +12,9 @@ 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, 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 @@ -74,8 +74,17 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut childCtx.tools.register({ ...schemaEntry, - execute(args: unknown, exec: ToolExecution): Promise { - const violations = validateStructuredValue(schema, args) + 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. if (violations.length > 0) throw new ToolArgsError(violations) @@ -83,7 +92,7 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut // 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 6cacc6f0c8..1c08488fc2 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -10,8 +10,8 @@ import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' -import type { Config as ToolConfig, StructuredOutputSchema } from '@deepseek-ai/dsh-tools' -import { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' +import type { Config as ToolConfig, ObjectJsonSchema } 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 { @@ -39,7 +39,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'], @@ -97,10 +97,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() }) @@ -131,15 +136,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') @@ -159,15 +164,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 @@ -196,15 +201,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 @@ -332,17 +337,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 () => { @@ -450,8 +455,10 @@ describe('in-process structured output', () => { expect(result.structured).toEqual({ answer: 12 }) const request = adapter.requests[0]! expect(toolNames(request)).toEqual([RUN_CODE_NAME]) - expect(request.system).toContain('declare const tools:') - expect(request.system).toContain('structured_output(args:') + expect(request.system).toContain('interface ToolArgsMap') + expect(request.system).toContain('interface ToolOutputMap') + expect(request.system).toContain('recorded: true;') + expect(request.system).toContain('Promise') expect(request.system).toContain(STRUCTURED_OUTPUT_INSTRUCTION) await run.dispose() }) @@ -559,7 +566,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'], @@ -601,12 +608,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 @@ -659,7 +666,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 () => { @@ -671,7 +678,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 7ba0551f34..8952c29be7 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -13,6 +13,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] @@ -393,10 +394,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/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 7031bd0ad3..7d6df34ffd 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'> @@ -73,11 +73,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/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 a84041d290..4eb29d0c6e 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. @@ -305,7 +345,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( @@ -324,7 +364,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 8dda16f6df..8468216d49 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -65,6 +65,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') }) @@ -445,7 +451,10 @@ describe('dsh-tool-subagent', () => { const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }, { signal: controller.signal }) expect(sawAborted).not.toHaveBeenCalled() expect(result.isError).toBe(true) - expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }) + expect(result.error).toEqual({ + message: 'tool call aborted before dispatch', + info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + }) }) it('tools depend on the service: no `subagent` tool without ctx.subagents', async () => { @@ -643,6 +652,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({ @@ -679,7 +690,10 @@ describe('dsh-tool-subagent background mode', () => { controller.abort() const result = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true }, { agent: parent, signal: controller.signal }) expect(result.isError).toBe(true) - expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }) + expect(result.error).toEqual({ + message: 'tool call aborted before dispatch', + info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + }) expect(text(result)).toBe('Error: tool call aborted before dispatch') }) 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 (: